From 18782e91041f6bb063b6c80c0c60d375d81fb654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 09:46:33 +0200 Subject: [PATCH 001/147] docs: four parts join 2.0.0: Result.Catch, type-argument constraints, a constructor rule and the text guards (decision 43) Reopens the goals: no Try type, since it is Result renamed; no Unit, since Result is the value-less shape. Constraints a type parameter cannot declare and the parameterless-constructor guarantee ship as attributes and analyzer rules (CMTK0010, CMTK0011), not aspects. The text guards ship as string extensions and TextFault in the base package, with no value object and no .Text package. Co-Authored-By: Claude Fable 5.1 --- docs/plan.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 6 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index 33d6560..f5a928e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -632,6 +632,107 @@ Each phase ends green, and its guards have been proven by seeding the defect It belongs in the base package, its OpenAPI part in the ASP.NET satellite. It is not ASP.NET Core 10's JSON Patch (RFC 6902, a list of operations). + - **`Result.Catch`: an exception as an outcome**, proposed 2026-10-13. A call that throws has no + way into a `Result` today, so every caller writes the `try`/`catch` by hand, and a `Try` + type would be `Result` under a second name (§9). Four factories on the `Result` + static class, each returning a concrete result rather than a marker, since the delegate fixes + `T`: + - **`Catch(Func)`** to `Result`, and **`Catch(Action)`** to + `Result`, the value-less shape, which is why no `Unit` is needed. + - **`Catch(Func)`** to `Result`, catching that type and + letting every other exception through, so the error type says what was expected to fail. + - **`CatchAsync`** for each, over `Task`, `Task` and `ValueTask`. + - **A cancellation is not an outcome**: `OperationCanceledException` passes through every + overload. Caught, a cancelled request would read as a failed one and `Recover` would run on + it. + + `Map`, `Bind` and the rest stay as they are: a selector that throws propagates, as it does + now, so the one place an exception becomes data is the one that says so. A spike must answer, + in [spikes/ResultCatch](../spikes/ResultCatch/README.md): + - **Inference**: `Result.Catch(() => int.Parse(s))` infers `T`; `Catch` needs + both type arguments spelled, since C# infers all or none, unless a different shape avoids it. + - **The async trap**: `Result.Catch(() => client.GetAsync(url))` binds `T` to a `Task` and + catches nothing the task throws. Whether an `[Obsolete]` error overload, a CMTK rule, or + both, refuses it. + - **`ToHttpResult()` over `Result`** is a compile error, since an exception is + not a fault a client acts on. + - **Native AOT**: nothing new is reflected, and the smoke test's `aot` consumer says so. + - **Constraints a type parameter cannot declare**, proposed 2026-10-13. C# has `where T : class`, + `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` or `record`. A generic + base whose type argument must be an abstract record, such as the command or event family of an + aggregate, gets no help from the compiler, and the closed type that violates it compiles. + Attributes on a generic parameter in the base package, `[Abstract]`, `[Sealed]`, `[Record]` and + `[Concrete]`, and one analyzer rule, CMTK0010, reported at every closed use: a base type, an + implemented interface, a field, a parameter, a `typeof`, a call with its type arguments + inferred. An analyzer rather than an aspect, because the violation is at the use site, which + need not compile with Metalama, and the base package is Metalama-free (§3); an aspect on the + parameter would see the declaration and never the use. A spike must answer, in + [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md): + - **Forwarding**: `class Sub : Base` where `Base<[Abstract] T>` is a violation unless `U` + carries `[Abstract]` too, or the hole is the whole rule. + - **Every use site** is reached, inferred type arguments and generic methods' parameters + included, and nothing is reported twice for one site. + - **Reflection** (`MakeGenericType`) is out of reach, and the README says so. + - **Error in a major version** is within decision 34; the rule never ships at Error in a minor. + - **`[EnsureParameterlessConstructor]`**, proposed 2026-10-13. A type the host creates by + reflection, an endpoint, a module, an entity configuration, needs a parameterless constructor + of a given accessibility, and C# checks that only through `new()`, which demands public. An + attribute in the base package, on an interface or a base class, and an analyzer rule, CMTK0011, + on every concrete type that implements or derives from it, directly or through another type, + with a code fix that writes the constructor. An analyzer and not an aspect, although an + `[Inheritable]` aspect introducing the constructor was the first shape considered: + - **The check is the feature.** The constructor is one empty line; the value is the error at + the type that lacks it, and that error has to appear in a project that never compiles with + Metalama, where an inheritable aspect is silent, this repository's characteristic failure. + - **A code fix beats an introduction.** An introduced constructor demands `partial` on every + implementer and is invisible in the source; the fix writes it where the reader looks, and a + type that declares no constructor needs nothing, since it already has a public one. + - **What the rule checks**: a parameterless constructor of the required accessibility exists, + or none is declared and public was asked; a record's copy constructor is not one; a wrong + accessibility is reported at the constructor, with the fix changing it; an abstract type or + an interface is not checked, only its concrete descendants. + - **Public aspects stay possible** (§5): an aspect that is itself the feature may be public, + with the attribute and its diagnostics as its surface. This one is not the first, because + its feature is the check. + - **Written text**, proposed 2026-10-13. Trimming, refusing the characters that render as + nothing and composing to NFC is written again in every application that stores what a person + typed, and `IsNullOrWhiteSpace` is what it usually gets, which lets a zero-width space, a Hangul + filler and NUL through, and keeps a name written with `ü` and the same name written with `u` and a + combining diaeresis as two rows of a unique index, rendering alike. Two string extensions in the base package, each returning + `Result` with `TextFault.Blank` or `TextFault.Hidden`: + - **`text.AsText()`**, for text a person wrote: trimmed, every space separator folded to + `U+0020`, NFC-composed, every character that renders as nothing refused, the two zero-width + joiners tolerated because they spell Persian, Arabic and Indic scripts and emoji sequences. + - **`text.AsIdentifier()`**, for a value another system issued, a subject claim, a session id: + trimmed, nothing else altered, since canonicalising somebody else's identifier changes an + attested value, and the joiners refused too, since no issuer needs one. + - **A null receiver is `Blank`**, since the extension is the door a bound request property goes + through, and binding enforces no non-nullability. + - **Two faults, not one**, because a required field makes `Blank` an error and an optional one + makes it absence; `Hidden` is a refusal in both. + - **The scan is by `Rune`**: a `char` loop reports `Surrogate` for a character above the BMP and + misses the tag characters used to hide text in text; an unpaired surrogate is `Hidden`, not a + throw, since `Normalize` and a database driver both throw on it. + - **Not refused**: unassigned code points, since .NET's tables lag Unicode and a name spelled + with a newly assigned character would be refused until the SDK caught up; confusable letters + across scripts, which is a comparison-key question and not a rejection. + + No value object ships with them, and no `.Text` package either (weighed 2026-10-13). The base + package is Metalama-free, so it cannot declare one for the generators, and a hand-written one is + what CMTK0002 exists to refuse. A package shipping a woven one would reference `.Generators` + with `PrivateAssets="all"` to keep Metalama away from its consumers, and would need an + exemption in the Metalama-boundary conventions, a package-validation baseline override, a + smoke consumer of its own under Native AOT, and a third state in the README's Metalama column. + All of that buys one line per field, and the type is a dead end: a field that later needs a + length limit cannot wrap it (CMTK1005) and is rewritten over `string`, and two fields of one + shared type lose what CMTK0008 and assignment give two named types. A consumer declares its + own in one line, `Create` being `value.AsText().Map(…)`, and names it for the field + (`Description`, `DisplayName`). The README shows that line. The extensions live in a class + named `StringTextExtensions`, so a consumer's value object named `Text` has no companion-class + collision with them (CMTK1007) and nothing is ambiguous with `System.Text`. If demand for a + ready-made type appears, a minor-version `CodoMetis.TypeKit.Text` holding `WrittenText` and + `IssuedIdentifier` under those conditions is additive; removing one after shipping is not. No + spike: every claim is a unit test over a string. - **The breaking reasons it waits for**, unchanged: Metalama 2027.0 stable (decision 4), Warnings promoted to Errors (decision 34), and C# 15 unions (.NET 11, GA 2026-11-10). The compiler-made union types have a public `object? Value` and are serialized by System.Text.Json, so @@ -966,8 +1067,16 @@ custom attributes. each aspect on 2026.1 as it lands, and use no 2027.0-only API. - **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`. Open point: ordering against a consumer's own aspects (fabric spike D). -- **Not in scope:** a `Try`/`Unit` type (§9 non-goals), clock-type analyzers, text-guarding types, - and decimal JSON converters (no aspect needs them, checked in phase 3). +- **Not in scope:** a `Try`/`Unit` type (§9 non-goals; an exception enters a result through + `Result.Catch`, phase 13), clock-type analyzers, and decimal JSON converters (no aspect needs them, + checked in phase 3). The text guards joined phase 13 on 2026-10-13, as string extensions in the + base package. +- **Public aspects (decided 2026-10-13, decision 43).** The value-object aspects are internal: the + fabric applies them, and their surface is what they generate. An aspect a consumer applies by hand + may be public when the aspect is the feature, with the attribute and its diagnostics as its + surface. None ships yet: the first candidate, a parameterless-constructor guarantee, went to the + analyzer and a code fix (phase 13), because its value is the error, which has to appear in a + project that never compiles with Metalama. ## 6. Test strategy @@ -1258,6 +1367,22 @@ Still open: would have been designed on enums with `FaultKind` attributes read by reflection, then rebuilt once smart enums exist. The cost is time: nothing ships before the later of .NET 11's GA and Metalama 2027.0 stable. +43. **Exceptions, type-argument constraints, a constructor guarantee and the text guards join + 2.0.0** (2026-10-13, phase 13). The goals were reopened and four candidates weighed. `Try` stays + out, since it is `Result` renamed, and `Result.Catch` takes its one job; `Unit` + stays out, since `Result` is the value-less shape. Constraints a type parameter cannot + declare (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`) ship as attributes and an analyzer + rule, not as an aspect, because the violation is at a use site that need not compile with + Metalama. `[EnsureParameterlessConstructor]` ships as an attribute, an analyzer rule and a code + fix, not as an inheritable aspect, because its value is the error and an introduced constructor + would demand `partial` of every implementer and be silent without Metalama. The written-text + guards ship as string extensions and a fault in the base package, with no value object and no + `.Text` package: the base package cannot declare one for the generators, a hand-written one is + what CMTK0002 refuses, a woven one in a package of its own costs a private generators reference, + a boundary exemption, a baseline override and a smoke consumer for one line per field, and the + type cannot be refined (CMTK1005) or told apart from another field of the same type (CMTK0008). + Public aspects are allowed in principle (§5) and none exists. Clock-type analyzers and + decimal JSON converters stay out. ## 9. Option and Result @@ -1267,8 +1392,9 @@ depend on them: `IValidatedValue.Create` returns a `Result`, and the generated ` `Option`. **Non-goals.** No Either, no Validation applicative, no effect or IO types, no higher-kinded type -emulation, no immutable collections, no `Try` or `Unit`. Anything that turns this into a second -LanguageExt is out. +emulation, no immutable collections. No `Try` type: it is `Result` under another name, +and an exception enters through `Result.Catch` (phase 13, decision 43). No `Unit`: `Result` +is the value-less shape. Anything that turns this into a second LanguageExt is out. **Shape, decided 2026-09-27.** - **`Result`, value first.** Error-first (`Either e a`) exists in Haskell and Scala @@ -1407,8 +1533,9 @@ Attributes stay in `CodoMetis.TypeKit`. The analyzer resolves them and the inter Inside the analyzer, rules that react to attributes are kept apart from value-object rules, so the split stays mechanical if a generic analyzer package is ever wanted. -**Scope.** Code that compiles but is wrong about value objects, `Result`/`Option` and types that -forbid `default`. General linting (clock types, style) stays out, since Meziantou and Roslynator +**Scope.** Code that compiles but is wrong about value objects, `Result`/`Option`, types that +forbid `default`, and what a type declares by attribute because C# cannot express it (a constraint +on a type parameter, a constructor a host needs). General linting (clock types, style) stays out, since Meziantou and Roslynator exist. | Id | Rule | Severity | Status | @@ -1422,6 +1549,8 @@ exist. | CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | +| CMTK0010 | A type argument that violates a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a `typeof`, a call with inferred type arguments. A type parameter forwarded without the same attribute is a violation too. Reflection is out of reach | Error | proposed for 2.0.0 (phase 13), a major, so Error is within decision 34 | +| CMTK0011 | A concrete type implementing or deriving from a type marked `[EnsureParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a type declaring no constructor satisfies `Public`. A wrong accessibility is reported at the constructor. The code fix writes or changes the constructor | Error | proposed for 2.0.0 (phase 13) | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | **Rules that read calls see the source before weaving (measured 2026-09-28).** Metalama runs From 38b3ff0b01c89140542eac30ee8202989c703be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 09:46:33 +0200 Subject: [PATCH 002/147] spike: outline ResultCatch and TypeArgumentConstraints, the claims each part must settle Co-Authored-By: Claude Fable 5.1 --- spikes/ResultCatch/README.md | 46 +++++++++++++++++++++++ spikes/TypeArgumentConstraints/README.md | 47 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 spikes/ResultCatch/README.md create mode 100644 spikes/TypeArgumentConstraints/README.md diff --git a/spikes/ResultCatch/README.md b/spikes/ResultCatch/README.md new file mode 100644 index 0000000..fbecc9a --- /dev/null +++ b/spikes/ResultCatch/README.md @@ -0,0 +1,46 @@ +# Spike: can an exception enter a `Result` through one factory, without a `Try` type? + +**Status: outline, not yet run.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 +(2.0.0) in [docs/plan.md](../../docs/plan.md) §2, decision 43. §9 keeps `Try` and `Unit` out: +a `Try` is `Result` under a second name, and `Result` is the value-less +shape a `Unit` would stand in for. + +**Question.** A call that throws has no way into a `Result` today, so every caller writes the +`try`/`catch` by hand. Can four factories on the `Result` static class take that job, each +returning a concrete result rather than a marker, with the combinators unchanged? + +1. **`Catch(Func)`** to `Result`, and **`Catch(Action)`** to + `Result`. +2. **`Catch(Func)`** to `Result`, catching that type and letting + every other exception through. +3. **`CatchAsync`** for each, over `Task`, `Task` and `ValueTask`. +4. **A cancellation is not an outcome**: `OperationCanceledException` passes through every overload. + +**Answer: pending.** + +## The candidate (planned) + +A console project on the packed base package: `Candidate.cs` holds the factories as the base +package would ship them, `Program.cs` runs each claim and prints the outcome, and `Trap.cs`, behind +`#if TRAP`, holds the calls that must not compile, built once with `-p:DefineConstants=TRAP` to +read the errors. An `-p:Aot=true` publish repeats the run under Native AOT. + +## Findings + +Each row is a claim the spike must settle. A seed that breaks the guard proves each one, as in +`.claude/skills/verify-the-guard`. + +| # | Claim | Result | +|---|---|---| +| 1 | `Result.Catch(() => int.Parse(s))` infers `T`, and the result is `Result`, not a marker | | +| 2 | `Catch(Action)` returns `Result`, so no `Unit` is needed for a call without a value | | +| 3 | `Catch` catches that type only; another exception propagates with its stack intact | | +| 4 | `Catch` needs both type arguments spelled (C# infers all or none), or a shape exists that infers `T` from the delegate and takes `TException` alone | | +| 5 | The async trap: `Result.Catch(() => client.GetAsync(url))` is refused, by an `[Obsolete]` error overload over `Func`/`Func>`, by a CMTK rule, or both; `CatchAsync` is the way | | +| 6 | `CatchAsync` over `Task`, `Task` and `ValueTask` catches an exception the awaited task throws, not only one thrown before the first await | | +| 7 | `OperationCanceledException` passes through every overload, sync and async, and a cancelled token surfaces as cancellation, never as an error | | +| 8 | `Map`, `Bind` and the query-syntax `Select` are unchanged: a selector that throws propagates | | +| 9 | `ToHttpResult()` over `Result` is a compile error | | +| 10 | A caught exception is handed out by `TryGetError` and `Match` as the instance thrown; nothing wraps it | | +| 11 | CMTK0003 reports a dropped `Result.Catch(...)` as it reports any dropped result | | +| 12 | Native AOT: no new trim or AOT warning, in the packages or in the consumer | | diff --git a/spikes/TypeArgumentConstraints/README.md b/spikes/TypeArgumentConstraints/README.md new file mode 100644 index 0000000..2309720 --- /dev/null +++ b/spikes/TypeArgumentConstraints/README.md @@ -0,0 +1,47 @@ +# Spike: can an attribute on a type parameter constrain its arguments where C# cannot? + +**Status: outline, not yet run.** Proposed 2026-10-13, from the "Constraints a type parameter +cannot declare" part of phase 13 (2.0.0) in [docs/plan.md](../../docs/plan.md) §2, decision 43, and +the CMTK0010 row of §10. + +**Question.** C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : +abstract`, `sealed` or `record`. A generic base whose type argument must be an abstract record gets +no help from the compiler, and the closed type that violates it compiles. Can attributes on the +parameter, `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` in the base package, and one +analyzer rule, CMTK0010, make the violation an error at every closed use, in every consumer, +without Metalama? + +An analyzer rather than an aspect, because an aspect on the parameter sees the declaration and +never the use, and the use site need not compile with Metalama (§3: the base package is +Metalama-free, and discovery is by the attribute symbol). + +**Answer: pending.** + +## The candidate (planned) + +The layout follows [spikes/FabricSpike](../FabricSpike/README.md): an analyzer project +(`Candidate`, netstandard2.0, the rule as `.Analyzers` would ship it), a library declaring the +attributes and a `Base<[Abstract] TCommand, [Record] TEvent>`, and a consumer project with one +seeded violation per claim, each on its own line, so `dotnet build` output is the measurement: the +set of reported lines must equal the set of seeded lines. + +## Findings + +Each row is a claim the spike must settle. A seed that breaks the guard proves each one, as in +`.claude/skills/verify-the-guard`. + +| # | Claim | Result | +|---|---|---| +| 1 | A base type `: Base` is reported at the type argument, naming the parameter, the argument and the constraint | | +| 2 | An implemented generic interface, a field, a property, a parameter, a return type and a local are each reported | | +| 3 | A `typeof(Base)` and a generic method call with explicit type arguments are reported | | +| 4 | A generic method call with inferred type arguments is reported, at the call, since no argument is written | | +| 5 | Forwarding: `class Sub : Base` is reported unless `U` carries `[Abstract]` itself; with it, `Sub` is reported at its own closed use | | +| 6 | A constraint on a type parameter of a generic method (`void M<[Sealed] T>()`) is checked at its calls | | +| 7 | One site is reported once, where a type appears in both syntax and a symbol action | | +| 8 | `[Record]` accepts a record class and a record struct; `[Abstract]` accepts an abstract class or record and refuses an interface; `[Sealed]` accepts a sealed class, a struct and a record struct; `[Concrete]` refuses an abstract type and an interface | | +| 9 | The four attributes compose: `[Abstract, Record]` requires both | | +| 10 | The open generic itself (`Base` inside its own body) is never reported | | +| 11 | A closed use in a Razor component is reported (§10, generated code with a mapped location) | | +| 12 | `MakeGenericType` at run time is out of reach, stated in the README, and the attribute carries no run-time check | | +| 13 | The attributes have `AttributeTargets.GenericParameter` only, so a misplaced one is CS0592 | | From 26f887ad868fe7a5ab1c5150c90c476394d222f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 09:52:34 +0200 Subject: [PATCH 003/147] docs: pin 2.0.0 to net10.0 and Metalama 2026.1, and order its build (decision 44) The version number names the API, never the runtime: a net11.0-only 2.0.0 would leave the .NET 10 LTS line on 1.5.x for an STS release. net11.0 joins a 2.x minor when a feature needs it; C# 15 unions and the net10.0 drop are 3.0.0. The preview becomes 2.0.0's own, from main. Co-Authored-By: Claude Fable 5.1 --- docs/plan.md | 96 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index f5a928e..72a773a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,7 +2,7 @@ Status: **1.0.0 released 2026-09-28** (nuget.org, GitHub release `v1.0.0`). Phases 0 to 11 are done; what is left for 1.x is listed at the end of phase 11, and package validation holds every pack to the -1.5.0 surface. Phase 12 is 1.5.0, released 2026-10-11; 1.5.1 followed the same day with a fix to parameter schemas. Phase 13 is the next release, 2.0.0 (decision 42). The decisions are in §8, Native AOT in §11. The fabric spike +1.5.0 surface. Phase 12 is 1.5.0, released 2026-10-11; 1.5.1 followed the same day with a fix to parameter schemas. Phase 13 is the next release, 2.0.0 (decisions 42 and 44: `net10.0`, Metalama 2026.1). The decisions are in §8, Native AOT in §11. The fabric spike ([spikes/FabricSpike](../spikes/FabricSpike/README.md)), the translation comparison ([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)), the EF mapping spike ([spikes/EfMapping](../spikes/EfMapping/README.md)) and the OpenAPI spike @@ -536,10 +536,30 @@ Each phase ends green, and its guards have been proven by seeding the defect `RangeSet<,>`, whose equality is a value's, though `IEquatable` alone is no criterion (`ImmutableArray`). -13. **2.0.0: closed sets, fault responses and merge patches.** Next; not started. Scoped 2026-10-12 - (decision 42): the features proposed for 1.x ship in the major that already waits for the three - breaking reasons below, not as a 1.6.0 before it, because the smart-enum family changes what a - fault is, and fault responses built on enums first would be built twice. +13. **2.0.0: closed sets, fault responses, merge patches, exceptions, constraints and text.** Next; + not started. Scoped 2026-10-12 (decision 42): the features proposed for 1.x ship in the major, + not as a 1.6.0 before it, because the smart-enum family changes what a fault is, and fault + responses built on enums first would be built twice. Pinned 2026-10-13 (decision 44) to + `net10.0` and Metalama 2026.1: nothing in it needs .NET 11, C# 15 unions or Metalama 2027.0, and + it waits for none of them. The build order, each step a commit with its tests, and a spike + before the part where one is listed: + 1. **Open the release**: `Version` 2.0.0 and a `## 2.0.0 — Unreleased` changelog section. The + package-validation baseline stays 1.5.0 and reports nothing until the first intended break. + 2. **The text guards** (below): no spike. + 3. **`Result.Catch`**, after [spikes/ResultCatch](../spikes/ResultCatch/README.md), since the + async trap can change the shape. + 4. **CMTK0011 and its code fix**: pure Roslyn, no spike. + 5. **CMTK0010**, after [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md), + since the forwarding claim decides whether the rule is sound. + 6. **`Patch`**, after its spike, in the base package and the ASP.NET satellite. It depends + on nothing above and nothing below. + 7. **The closed-set family**, after its spike: the generators, then each satellite. + 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), + last, since they read a smart enum's kind. + 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict + on each intended break. + 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test + against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the release's headline. A member is a named instance with constant data. The generators emit `All`, parsing and formatting by name, JSON, an OpenAPI string enum, and a `Match` with one required @@ -564,8 +584,8 @@ Each phase ends green, and its guards have been proven by seeding the defect - **Which status for each kind**, and whether a host may change a kind's default. - **The startup check holds**: a member with neither a kind nor an explicit mapping still stops the host. - - **C# 15 unions**: whether a smart enum is best expressed as one, or stays a generated type with - static members. + - **A generated type with static members**, not a C# 15 union: 2.0.0 is `net10.0` (decision + 44), and a union-shaped closed set is 3.0.0's question. - **Persistence**: an EF column by name, mapped by `.EntityFrameworkCore` as value objects are. - **Fault responses a client can act on**, proposed 2026-10-12. Decision 38's response carries a status and the fault's bare name, and its success is `200` or `204`. An application whose client shows a refusal to a person, creates resources, or @@ -733,34 +753,29 @@ Each phase ends green, and its guards have been proven by seeding the defect ready-made type appears, a minor-version `CodoMetis.TypeKit.Text` holding `WrittenText` and `IssuedIdentifier` under those conditions is additive; removing one after shipping is not. No spike: every claim is a unit test over a string. - - **The breaking reasons it waits for**, unchanged: Metalama 2027.0 stable (decision 4), Warnings - promoted to Errors (decision 34), and C# 15 unions (.NET 11, GA 2026-11-10). The compiler-made - union types have a public `object? Value` and are serialized by System.Text.Json, so - `Option`/`Result` take part in their pattern matching only after a spike shows neither returns. - - **Open: does 2.0.0 move to .NET 11?** Decision 20 is `net10.0` only, and C# 15 unions need - .NET 11. A 2.0.0 that uses them can target `net11.0` alone, which leaves a consumer on the .NET - 10 LTS line on 1.x. It can target both, with unions only behind `net11.0`, at the cost of a - second target in every satellite (EF Core 11, Microsoft.AspNetCore.OpenApi 11) and a Native - AOT smoke run per target. Or it can stay on `net10.0` and leave unions to a later major, which - also settles the smart-enum spike's union question. The hinge is whether consumers on the LTS - line get the fault responses, smart enums and `Patch` at all, and the preview below - answers it with measurements rather than in advance. - - **A preview to evaluate first** (proposed 2026-10-12): `2.0.0-preview.N`, built on the .NET 11 - SDK and the Metalama 2027.0 preview, on a branch of its own, before either is stable. It - measures what C# 15 unions and 2027.0 buy before decisions 4 and 20 move for the release. - `main` keeps both decisions meanwhile, and a 1.5.x fix still ships from it. The release - workflow takes a preview tag as it is, since `v2.0.0-preview.1` passes the tag check against - `Version` and the changelog check against a dated `## 2.0.0-preview.1` section. The SDK comes - from `global.json`, though, so the branch pins the .NET 11 SDK there. The preview must answer: - - **The .NET 11 question above**, from what the unions actually change in `Option`, `Result` - and the smart-enum family. - - **Metalama 2027.0 on the .NET 11 SDK**: the aspects, the fabric and the analyzers build and - weave, and `[Durable]` returns to the `_value` placeholder (decision 4). - - **Package validation in a major**: against the 1.5.0 baseline it reports the intended breaks. - Whether they are suppressed per break or the baseline lapses for 2.0.0 is decided there, so - an unintended break still fails the pack. - - **Where it is published**: nuget.org, where the suffix marks it a pre-release, or a local - feed for the evaluation alone. + - **What 2.0.0 breaks**: Warnings promoted to Errors (decision 34), CMTK0010 and CMTK0011 at + Error, and whatever else the 1.5.0 baseline reports as intended. Whether an intended break is + suppressed per break or the baseline lapses for 2.0.0 is decided at the first one, so an + unintended break still fails the pack. + - **What it does not wait for** (decision 44): Metalama 2027.0 stable, .NET 11 and C# 15 unions. + The compiler-made union types have a public `object? Value` and are serialized by + System.Text.Json, so `Option`/`Result` take part in their pattern matching only after a spike + shows neither returns, and that spike belongs to 3.0.0. + - **What comes after**: the Metalama 2027.0 upgrade (decision 4) joins the first release after + it is stable, as a minor if a consumer's own aspects beside the packed generators still build + and weave, measured as the fabric spike measured 2026.1 against the 2027.0 preview, and as a + major otherwise; `[Durable]` returns to the `_value` placeholder with it. A `net11.0` target + joins as a second target in a 2.x minor when a feature needs it, at the cost priced on + 2026-10-12: EF Core 11 and Microsoft.AspNetCore.OpenApi 11 in the satellites and a Native AOT + smoke run per target. C# 15 unions and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS + (November 2027) or to the end of .NET 10's life, whichever its consumers need. + - **The preview** (`2.0.0-preview.N`) is 2.0.0's own, from `main`, on the .NET 10 SDK and + Metalama 2026.1, published to nuget.org once the closed-set family and the fault responses + exist, so the surface gets consumer feedback before the tag. The release workflow takes a + preview tag as it is, since `v2.0.0-preview.1` passes the tag check against `Version` and the + changelog check against a dated `## 2.0.0-preview.1` section. The measurement of unions and + Metalama 2027.0 on the .NET 11 SDK moves to a 3.0.0 spike on a branch of its own, with no + release attached. ## 3. Discovery is by interface @@ -1383,6 +1398,17 @@ Still open: type cannot be refined (CMTK1005) or told apart from another field of the same type (CMTK0008). Public aspects are allowed in principle (§5) and none exists. Clock-type analyzers and decimal JSON converters stay out. +44. **2.0.0 is `net10.0` on Metalama 2026.1; the version number names the API, never the runtime** + (2026-10-13, phase 13). Dropping a target framework is a break of its own, so a `net11.0`-only + 2.0.0 would have left every consumer on the .NET 10 LTS line (supported to November 2028) on + 1.5.x, without the fault responses, the closed sets or `Patch`, for a .NET 11 that is STS. The + breaks 2.0.0 actually has, decision 34's promotions and two rules at Error, are breaks on + `net10.0`, and none of its parts needs C# 15 unions. A `net11.0` target is additive and joins a + 2.x minor when a feature needs it; unions in the public surface would be two contracts under one + version, so they and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS. Metalama 2027.0 joins + the first release after it is stable, minor or major as the fabric measurement says. The + `2.0.0-preview.N` branch of 2026-10-12 becomes a preview of 2.0.0 itself from `main`; the .NET 11 + measurement is a 3.0.0 spike with no release attached. ## 9. Option and Result From c09b230b4ea8eaf21a54baa15e1658a598b24a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 10:31:18 +0200 Subject: [PATCH 004/147] build: open 2.0.0, and move the package-validation baseline to 1.5.1 The baseline had stayed at 1.5.0 when 1.5.1 shipped; the convention that checks it excludes the version being built, so it only saw the miss once the version moved to 2.0.0. The changelog section reads Unreleased. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +++++ Directory.Build.props | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fec0219..4f6a90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ A version's heading reads `## — Unreleased` until the release is pre the release date. The release workflow refuses a tag whose section still says `Unreleased`, and the section is the GitHub release's notes. +## 2.0.0 — Unreleased + +A major version: it targets `net10.0` and Metalama 2026.1 as 1.x does, and the breaks are the ones +Semantic Versioning reserves for a major, listed under Changed as they land. + ## 1.5.1 — 2026-10-11 ### Fixed diff --git a/Directory.Build.props b/Directory.Build.props index 31b0814..4e8b881 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.5.1 + 2.0.0 CaffeinatedCoder Copyright (c) 2026 CaffeinatedCoder MIT @@ -43,7 +43,7 @@ --> true - 1.5.0 + 1.5.1 From 8d1fc4ca99686d964dcc2104c54727a57d820417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 10:31:18 +0200 Subject: [PATCH 005/147] feat: the text guards, AsText() and AsIdentifier() on string, with TextFault AsText() is for text a person wrote: trimmed, space separators folded to U+0020, composed to NFC, refused when a character renders as nothing; the two zero-width joiners are kept. AsIdentifier() is for a value another system issued: trimmed, otherwise as it arrived, joiners refused. Both scan by Rune, answer Result, and read a null as Blank. The tests pin the refused characters by code point; a seed that dropped the named invisible list failed twelve of them. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 12 + README.md | 2 +- docs/plan.md | 11 +- src/CodoMetis.TypeKit/README.md | 45 +++- src/CodoMetis.TypeKit/StringTextExtensions.cs | 168 +++++++++++++ src/CodoMetis.TypeKit/TextFault.cs | 16 ++ .../Base/TextSamples.cs | 55 +++++ .../StringTextExtensionsTests.cs | 220 ++++++++++++++++++ 8 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 src/CodoMetis.TypeKit/StringTextExtensions.cs create mode 100644 src/CodoMetis.TypeKit/TextFault.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Base/TextSamples.cs create mode 100644 test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6a90e..ac40e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ section is the GitHub release's notes. A major version: it targets `net10.0` and Metalama 2026.1 as 1.x does, and the breaks are the ones Semantic Versioning reserves for a major, listed under Changed as they land. +### Added + +- **`CodoMetis.TypeKit`**: the text guards. `text.AsText()` is for text a person wrote: trimmed, every + space separator folded to an ordinary space, composed to NFC so one name has one spelling, and + refused when it carries a character that renders as nothing, such as a zero-width space, a Hangul + filler or NUL, which `IsNullOrWhiteSpace` lets through; the two zero-width joiners are kept, since + they spell Persian, Arabic and Indic scripts and emoji sequences. `text.AsIdentifier()` is for a + value another system issued, a subject claim or a session id: trimmed and otherwise left as it + arrived, with the joiners refused too. Both answer `Result`, where `Blank` is + the caller's to read as an error or as absence and `Hidden` is a refusal in both; a null receiver + is `Blank`. A value object over either is one line, shown in the README. + ## 1.5.1 — 2026-10-11 ### Fixed diff --git a/README.md b/README.md index fb3be29..aec1b85 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ your own, and it can be declared inside another type. | Package | Role | Metalama | |---|---|---| -| [CodoMetis.TypeKit](src/CodoMetis.TypeKit/README.md) | `Option`, `Result`, `Result`, the value-object contracts, and the analyzers that guard them | no | +| [CodoMetis.TypeKit](src/CodoMetis.TypeKit/README.md) | `Option`, `Result`, `Result`, the value-object contracts, the text guards `AsText()` and `AsIdentifier()`, and the analyzers that guard them | no | | [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0009: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values. In Razor components too. Arrives with the base package | no | | [CodoMetis.TypeKit.Generators](src/CodoMetis.TypeKit.Generators/README.md) | The compile-time generation, in the project that declares value objects | yes | | [CodoMetis.TypeKit.EntityFrameworkCore](src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) | Value objects as columns with nothing registered per type, and `.Value` in LINQ | no | diff --git a/docs/plan.md b/docs/plan.md index 72a773a..2a91c7f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -543,9 +543,14 @@ Each phase ends green, and its guards have been proven by seeding the defect `net10.0` and Metalama 2026.1: nothing in it needs .NET 11, C# 15 unions or Metalama 2027.0, and it waits for none of them. The build order, each step a commit with its tests, and a spike before the part where one is listed: - 1. **Open the release**: `Version` 2.0.0 and a `## 2.0.0 — Unreleased` changelog section. The - package-validation baseline stays 1.5.0 and reports nothing until the first intended break. - 2. **The text guards** (below): no spike. + 1. **Open the release** ✅ 2026-10-13: `Version` 2.0.0 and a `## 2.0.0 — Unreleased` changelog + section. The package-validation baseline is 1.5.1, the last release (it had stayed at 1.5.0, + which `ChangelogTests` only sees once the version being built moves on), and reports nothing + until the first intended break. After moving a baseline, restore the solution: the conventions + pack with `--no-restore`, and a baseline not yet in the local cache fails every pack. + 2. **The text guards** ✅ 2026-10-13: `StringTextExtensions` and `TextFault` in the base package, + `StringTextExtensionsTests` (the character table, pinned by code point), the README section + "Written text" with its two compiled samples. No spike. 3. **`Result.Catch`**, after [spikes/ResultCatch](../spikes/ResultCatch/README.md), since the async trap can change the shape. 4. **CMTK0011 and its code fix**: pure Roslyn, no spike. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 24ac7a2..b6dffbb 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -264,6 +264,49 @@ var storeOptions = new JsonSerializerOptions { Converters = { new StoredJsonConv makes the analyzer refuse `default` and `new()` of it, as it does for `Option`, `Result` and every value object. +## Written text + +Every application that stores what a person typed trims it and refuses a blank. Few refuse a +zero-width space, a Hangul filler or NUL, which `IsNullOrWhiteSpace` lets through and `Trim` +leaves in place, and fewer compose it, so a name written with `ü` and the same name written with +`u` and a combining diaeresis are two rows of a unique index that render alike. Two extensions on +`string` do all of it and answer with a `Result`: + +```csharp +var name = " Jean\u00A0Paul ".AsText(); // Success: Jean Paul, trimmed, the no-break space folded to an ordinary one +var subject = "subject\u200Bid".AsIdentifier(); // Error: Hidden, a zero-width space +var missing = ((string?)null).AsText(); // Error: Blank +``` + +Which one to call is decided by who chose the value. `AsText()` is for text a person wrote: it +trims, folds every space separator to an ordinary space, composes to NFC and refuses any character +that renders as nothing, keeping the two zero-width joiners because they spell Persian, Arabic and +Indic scripts and hold an emoji sequence together. `AsIdentifier()` is for a value another system +issued, a subject claim, a session id, a key: it trims and alters nothing else, since an issued +value is compared against what its issuer sends next time, and it refuses the joiners too. Both +scan by `Rune`, so a character above the BMP is seen for what it is, and an unpaired surrogate is +`Hidden` rather than an exception. + +`TextFault` has two members because the caller decides what a blank means: a required field makes +`Blank` an error, an optional one makes it absence. `Hidden` is a refusal in both. A null is +`Blank`, since the extension is the door a bound request property goes through and binding +enforces no non-nullability. + +A value object over either guard is one line, and named for the field rather than for the guard, +since `Description` says more than `Text` and two fields of one type can be assigned to each other: + +```csharp +public readonly partial record struct Description : IValidatedValue +{ + public static Result Create(string value) => + value.AsText().Map(text => new Description(text)); +} +``` + +Not refused on purpose: unassigned code points, since the runtime's Unicode tables lag the +standard and a name spelled with a newly assigned character would be refused until the SDK caught +up; and confusable letters across scripts, which is a comparison question and not a rejection. + ## The analyzers They arrive with this package, so whoever can see the interfaces gets the guard: @@ -411,7 +454,7 @@ stored. ## Where things are -`Option`, `Result`, `OrderId.New()` and `[RequireCustomInitialization]` live in `CodoMetis.TypeKit`; +`Option`, `Result`, `OrderId.New()`, `AsText()`, `AsIdentifier()` and `[RequireCustomInitialization]` live in `CodoMetis.TypeKit`; every value-object contract in `CodoMetis.TypeKit.ValueObjects`. What only the generated code and the satellites call is in `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense. Targets .NET 10. Source and issues: [github.com/CaffeinatedCoder/CodoMetis.TypeKit](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit). diff --git a/src/CodoMetis.TypeKit/StringTextExtensions.cs b/src/CodoMetis.TypeKit/StringTextExtensions.cs new file mode 100644 index 0000000..1c9bf5c --- /dev/null +++ b/src/CodoMetis.TypeKit/StringTextExtensions.cs @@ -0,0 +1,168 @@ +using System.Buffers; +using System.Globalization; +using System.Text; + +namespace CodoMetis.TypeKit; + +/// +/// The two ways a string becomes text an application keeps. +/// is not one of them: it lets through a zero-width space, a Hangul filler and NUL, and +/// removes none of them, since none is whitespace. +/// +/// +/// +/// Which one to call is decided by who chose the value. takes text a person +/// wrote, a value another system issued; they differ in what they +/// tolerate and in whether they canonicalise. +/// +/// +/// Both scan by , and anything extending them must stay that way: a per- +/// category test cannot see a character above the Basic Multilingual Plane, and reports +/// where the real category is , +/// which is how the tag characters used to hide text inside text pass such a test. An unpaired surrogate +/// is not a character: throws on it, so it is refused as +/// rather than thrown over. +/// +/// +/// Not refused: unassigned code points, since the runtime's Unicode tables lag the standard and a +/// name spelled with a newly assigned character would be refused until the SDK caught up; and +/// confusable letters across scripts, which is a comparison question and not a rejection. +/// +/// +public static class StringTextExtensions +{ + private const int ZeroWidthNonJoiner = 0x200C; + private const int ZeroWidthJoiner = 0x200D; + + /// The string, which may be null: a null is . + extension(string? text) + { + /// + /// The string as text a person wrote, as it will be kept: trimmed, every space separator folded + /// to an ordinary space, composed to Unicode normalization form C, and refused if it carries a + /// character that renders as nothing. + /// + /// + /// + /// Neither canonicalisation is a convenience. Two canonically equivalent spellings of one name, + /// or one name spaced with a no-break space, compare unequal under every ordinal comparison and + /// under a unique index a database compares byte by byte, while rendering identically on every + /// screen. + /// + /// + /// The two zero-width joiners are kept: they are ordinary orthography in Persian, Arabic and + /// several Indic scripts, and a zero-width joiner is what holds an emoji sequence together. + /// + /// + /// + /// The text, or for a null, empty or whitespace-only string, or + /// for one carrying a character that renders as nothing. + /// + public Result AsText() => Clean(text, tolerateJoiners: true, canonicalize: true); + + /// + /// The string as a value another system issued, a subject claim, a session id, a key: trimmed of + /// surrounding whitespace, otherwise exactly as it arrived, and refused if it carries a + /// character that renders as nothing. + /// + /// + /// Nothing is composed or folded, since an issued value is compared against what its issuer + /// sends next time, and canonicalising it alters an attested value. Trimming survives because + /// surrounding whitespace is a transport artefact. The zero-width joiners + /// keeps are refused here, since no issuer needs one. + /// + /// + /// The value, or for a null, empty or whitespace-only string, or + /// for one carrying a character that renders as nothing. + /// + public Result AsIdentifier() => Clean(text, tolerateJoiners: false, canonicalize: false); + } + + private static Result Clean(string? text, bool tolerateJoiners, bool canonicalize) + { + var trimmed = text?.Trim() ?? string.Empty; + + if (trimmed.Length == 0) return Result.Error(TextFault.Blank); + if (IsHidden(trimmed, tolerateJoiners)) return Result.Error(TextFault.Hidden); + + return canonicalize ? FoldSpaces(trimmed).Normalize(NormalizationForm.FormC) : trimmed; + } + + /// + /// Every separator that renders as an ordinary space becomes U+0020, so one name spaced two + /// ways is one value. Runs on the written path only. + /// + private static string FoldSpaces(string text) + { + StringBuilder? folded = null; + + var rest = text.AsSpan(); + var position = 0; + + while (!rest.IsEmpty) + { + Rune.DecodeFromUtf16(rest, out var rune, out var consumed); + + if (rune.Value != ' ' && Rune.GetUnicodeCategory(rune) == UnicodeCategory.SpaceSeparator) + { + folded ??= new StringBuilder(text.Length).Append(text, 0, position); + folded.Append(' '); + } + else + { + folded?.Append(text, position, consumed); + } + + position += consumed; + rest = rest[consumed..]; + } + + return folded?.ToString() ?? text; + } + + private static bool IsHidden(string text, bool tolerateJoiners) + { + var rest = text.AsSpan(); + + while (!rest.IsEmpty) + { + if (Rune.DecodeFromUtf16(rest, out var rune, out var consumed) != OperationStatus.Done) + return true; + + if (!IsRendered(rune, tolerateJoiners)) return true; + + rest = rest[consumed..]; + } + + return false; + } + + private static bool IsRendered(Rune rune, bool tolerateJoiners) => + (tolerateJoiners && rune.Value is ZeroWidthNonJoiner or ZeroWidthJoiner) + || (Rune.GetUnicodeCategory(rune) switch + { + // Control covers NUL and the C0/C1 range; Format covers the zero-width spaces, the + // word joiner, the byte-order mark and the whole bidirectional family, including the + // overrides that reorder what is rendered around them. Private use and the two + // explicit separators render as whatever the font decides or as nothing at all. + UnicodeCategory.Control + or UnicodeCategory.Format + or UnicodeCategory.PrivateUse + or UnicodeCategory.LineSeparator + or UnicodeCategory.ParagraphSeparator => false, + + _ => !IsInvisibleDespiteItsCategory(rune) + }); + + /// + /// Characters whose whole purpose is to occupy a position without rendering, which Unicode + /// nevertheless files as letters, marks or symbols, so no category test reaches them. + /// + private static bool IsInvisibleDespiteItsCategory(Rune rune) => + rune.Value is 0x034F // COMBINING GRAPHEME JOINER (NonSpacingMark) + or 0x115F // HANGUL CHOSEONG FILLER (OtherLetter) + or 0x1160 // HANGUL JUNGSEONG FILLER (OtherLetter) + or 0x2800 // BRAILLE PATTERN BLANK (OtherSymbol) + or 0x3164 // HANGUL FILLER (OtherLetter) + or 0xFFA0; // HALFWIDTH HANGUL FILLER (OtherLetter) +} diff --git a/src/CodoMetis.TypeKit/TextFault.cs b/src/CodoMetis.TypeKit/TextFault.cs new file mode 100644 index 0000000..4099653 --- /dev/null +++ b/src/CodoMetis.TypeKit/TextFault.cs @@ -0,0 +1,16 @@ +namespace CodoMetis.TypeKit; + +/// Why a string is not the text it appears to be. +/// +/// Two faults rather than one because the caller decides what means: a required +/// field reads it as an error, an optional one as absence. is a refusal in both, +/// since nothing asks for a character that renders as nothing. +/// +public enum TextFault +{ + /// Null, empty, or nothing but whitespace. + Blank, + + /// Carries at least one character that renders as nothing. + Hidden +} diff --git a/test/CodoMetis.TypeKit.Samples/Base/TextSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/TextSamples.cs new file mode 100644 index 0000000..4811a6c --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Base/TextSamples.cs @@ -0,0 +1,55 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Base.Text; + +// sample: CodoMetis.TypeKit/text-value-object +public readonly partial record struct Description : IValidatedValue +{ + public static Result Create(string value) => + value.AsText().Map(text => new Description(text)); +} +// end sample + +public static class Guards +{ + public static (Result Name, Result Subject, Result Missing) Run() + { + // sample: CodoMetis.TypeKit/text + var name = " Jean\u00A0Paul ".AsText(); // Success: Jean Paul, trimmed, the no-break space folded to an ordinary one + var subject = "subject\u200Bid".AsIdentifier(); // Error: Hidden, a zero-width space + var missing = ((string?)null).AsText(); // Error: Blank + // end sample + + return (name, subject, missing); + } +} + +public sealed class TextTests +{ + private const string Readme = "src/CodoMetis.TypeKit/README.md"; + + private static string Render(Result result) => + result.Match(text => $"Success: {text}", fault => $"Error: {fault}"); + + [Fact] + public void Each_guard_answers_what_its_comment_states() + { + " Jean\u00A0Paul ".AsText().ShouldBeShownIn(Readme, Render); + "subject\u200Bid".AsIdentifier().ShouldBeShownIn(Readme, Render); + ((string?)null).AsText().ShouldBeShownIn(Readme, Render); + + var (name, subject, missing) = Guards.Run(); + Render(name).ShouldStartWith("Success: Jean Paul"); + Render(subject).ShouldBe("Error: Hidden"); + Render(missing).ShouldBe("Error: Blank"); + } + + [Fact] + public void The_value_object_applies_the_guard() + { + Description.Create(" Jean\u00A0Paul ").Match(d => d.Value, _ => "").ShouldBe("Jean Paul"); + Description.TryFrom("\u200B").IsNone().ShouldBeTrue(); + Description.Create("Mu\u0308ller").ShouldBe(Description.Create("M\u00FCller")); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs new file mode 100644 index 0000000..509ba9d --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs @@ -0,0 +1,220 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// The two guards a string passes before it is text an application keeps. +/// +/// +/// +/// The character table is the point of this file. Every entry is a code point that walks through +/// IsNullOrWhiteSpace and survives Trim, and between them they are refused for four +/// different structural reasons: category Control, category Format, a category that +/// claims "letter" or "symbol" while the glyph is empty, and a plane above the BMP. Pinning them by +/// value rather than by category means a runtime that reclassifies one fails here instead of +/// quietly reopening the defect. +/// +/// +/// Every entry is written as an escape and named in the second column, because the subject of this +/// file is characters a reader cannot see. The five above U+FFFF are the ones a plausible +/// implementation gets wrong: CharUnicodeInfo.GetUnicodeCategory over a +/// answers Surrogate for either half of a pair and never the category of the character the +/// pair encodes, so a per-char scan for Format accepts all five. +/// +/// +public sealed class StringTextExtensionsTests +{ + /// Refused by both guards, whichever way the value arrived. + public static TheoryData Hidden => new() + { + { "\u0000", "NUL" }, + { "\u0009", "TAB" }, + { "\u034F", "COMBINING GRAPHEME JOINER" }, + { "\u115F", "HANGUL CHOSEONG FILLER" }, + { "\u1160", "HANGUL JUNGSEONG FILLER" }, + { "\u180E", "MONGOLIAN VOWEL SEPARATOR" }, + { "\u200B", "ZERO WIDTH SPACE" }, + { "\u200E", "LEFT-TO-RIGHT MARK" }, + { "\u202E", "RIGHT-TO-LEFT OVERRIDE" }, + { "\u2028", "LINE SEPARATOR" }, + { "\u2029", "PARAGRAPH SEPARATOR" }, + { "\u2060", "WORD JOINER" }, + { "\u2800", "BRAILLE PATTERN BLANK" }, + { "\u3164", "HANGUL FILLER" }, + { "\uFEFF", "ZERO WIDTH NO-BREAK SPACE" }, + { "\uFFA0", "HALFWIDTH HANGUL FILLER" }, + { "\U0001D173", "MUSICAL SYMBOL BEGIN BEAM" }, + { "\U000E0001", "LANGUAGE TAG" }, + { "\U000E0041", "TAG LATIN CAPITAL A" }, + { "\U000E007F", "CANCEL TAG" }, + { "\U000F0000", "PRIVATE USE" } + }; + + /// + /// The two characters the split is about: orthography in Persian, Arabic and several Indic + /// scripts, and the glue inside an emoji sequence, so a name may carry them and a value another + /// system issued may not. + /// + public static TheoryData Joiners => new() + { + { "\u200C", "ZERO WIDTH NON-JOINER" }, + { "\u200D", "ZERO WIDTH JOINER" } + }; + + /// + /// Every entry renders exactly as an ordinary space and, before folding, made two values out of + /// one name. Confusable letters across scripts are deliberately not folded. + /// + public static TheoryData Spaces => new() + { + { "\u00A0", "NO-BREAK SPACE" }, + { "\u2002", "EN SPACE" }, + { "\u2007", "FIGURE SPACE" }, + { "\u202F", "NARROW NO-BREAK SPACE" }, + { "\u3000", "IDEOGRAPHIC SPACE" } + }; + + [Theory] + [MemberData(nameof(Hidden))] + public void A_character_that_renders_as_nothing_is_refused_as_text(string character, string name) => + Refusal($"Muster{character}mann".AsText()).ShouldBe(TextFault.Hidden, name); + + [Theory] + [MemberData(nameof(Hidden))] + public void A_character_that_renders_as_nothing_is_refused_as_an_identifier(string character, string name) => + Refusal($"subject{character}id".AsIdentifier()).ShouldBe(TextFault.Hidden, name); + + [Theory] + [MemberData(nameof(Joiners))] + public void A_joiner_is_orthography_in_text_and_noise_in_an_identifier(string character, string name) + { + var written = $"Muster{character}mann"; + + Kept(written.AsText()).ShouldBe(written, name); + Refusal(written.AsIdentifier()).ShouldBe(TextFault.Hidden, name); + } + + /// + /// A zero-width joiner inside an emoji sequence is the concrete reason the permissive guard + /// exists: refusing it would refuse a character people type on a phone keyboard. + /// + [Fact] + public void An_emoji_sequence_survives_as_text() + { + const string developer = "\U0001F469\u200D\U0001F4BB"; + + Kept(developer.AsText()).ShouldBe(developer); + } + + /// + /// The two faults stay apart because the caller decides what a blank means: a required field + /// turns it into an error, an optional one into absence. Collapsing them would make every + /// optional field swallow a hidden character. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void Text_with_nothing_in_it_is_blank_rather_than_hidden(string value) + { + Refusal(value.AsText()).ShouldBe(TextFault.Blank); + Refusal(value.AsIdentifier()).ShouldBe(TextFault.Blank); + } + + /// + /// The extension is the door a bound request property goes through, and binding enforces no + /// non-nullability, so the one input a caller cannot see coming is absorbed here. + /// + [Fact] + public void A_null_is_blank_rather_than_a_throw() + { + string? missing = null; + + Refusal(missing.AsText()).ShouldBe(TextFault.Blank); + Refusal(missing.AsIdentifier()).ShouldBe(TextFault.Blank); + } + + [Fact] + public void Text_that_is_nothing_but_a_hidden_character_is_hidden_rather_than_blank() => + Refusal("\u200B".AsText()).ShouldBe(TextFault.Hidden); + + /// + /// The half no character check can give. Without composition these are two names, two rows and + /// two entries in a unique index a database compares byte by byte, and they render identically + /// on every screen either way. + /// + [Fact] + public void Text_is_composed_so_that_one_name_has_one_spelling() + { + const string composed = "M\u00FCller"; + const string decomposed = "Mu\u0308ller"; + + decomposed.ShouldNotBe(composed); + + var normalized = Kept(decomposed.AsText()); + normalized.ShouldBe(composed); + normalized.Length.ShouldBe(6); + } + + /// + /// The one place the two guards disagree about content rather than about joiners: a value + /// another system issued is compared against what that system sends next, so this side does + /// not get to canonicalise it. + /// + [Fact] + public void An_identifier_is_left_in_the_spelling_it_arrived_in() + { + const string decomposed = "Mu\u0308ller"; + + Kept(decomposed.AsIdentifier()).ShouldBe(decomposed); + } + + /// + /// Not a category question: an unpaired surrogate is not a character at all. + /// string.Normalize throws on one, so refusing it here is what keeps a malformed string + /// from becoming an unhandled exception further down. + /// + [Fact] + public void An_unpaired_surrogate_is_refused_rather_than_thrown_over() + { + Refusal("Muster\uD834mann".AsText()).ShouldBe(TextFault.Hidden); + Refusal("Muster\uD834mann".AsIdentifier()).ShouldBe(TextFault.Hidden); + } + + [Fact] + public void Surrounding_whitespace_is_not_content() + { + Kept(" Erika Mustermann \n".AsText()).ShouldBe("Erika Mustermann"); + Kept(" 7f3a \n".AsIdentifier()).ShouldBe("7f3a"); + } + + [Theory] + [MemberData(nameof(Spaces))] + public void A_space_that_renders_as_an_ordinary_one_is_folded_to_it(string character, string name) => + Kept($"Jean{character}Paul".AsText()).ShouldBe("Jean Paul", name); + + /// + /// The written path folds; the identifier path alters nothing beyond the trim, because a value + /// another system issued is compared against what that system sends next time. + /// + [Fact] + public void An_identifier_keeps_its_own_spacing() + { + const string nonBreaking = "subject\u00A0id"; + + Kept(nonBreaking.AsIdentifier()).ShouldBe(nonBreaking); + } + + /// Text with nothing to fold or compose comes back as the same instance, not a copy. + [Fact] + public void Clean_text_is_returned_as_it_is() + { + const string clean = "Erika Mustermann"; + + ReferenceEquals(Kept(clean.AsText()), clean).ShouldBeTrue(); + } + + private static string Kept(Result result) => + result.Match(text => text, fault => throw new ShouldAssertException($"Refused as {fault}.")); + + private static TextFault Refusal(Result result) => + result.Match(text => throw new ShouldAssertException($"Kept \"{text}\"."), fault => fault); +} From 9c7a3143675c30f92f5058d6d7192997c8f32a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 10:43:43 +0200 Subject: [PATCH 006/147] spike: ResultCatch, run and answered: an exception enters a Result through one factory The async trap is an [Obsolete(error: true)] overload over a task-returning delegate, which the compiler picks over Func; a lambda that only throws needs its return type stated. No ValueTask overloads: ambiguous for every async lambda. Cancellation passes through; Native AOT clean. Co-Authored-By: Claude Fable 5.1 --- spikes/ResultCatch/.gitignore | 1 + spikes/ResultCatch/Candidate.cs | 107 +++++++++++++++++++++++ spikes/ResultCatch/Directory.Build.props | 3 + spikes/ResultCatch/Program.cs | 82 +++++++++++++++++ spikes/ResultCatch/README.md | 55 +++++++----- spikes/ResultCatch/ResultCatch.csproj | 26 ++++++ spikes/ResultCatch/Trap.cs | 23 +++++ 7 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 spikes/ResultCatch/.gitignore create mode 100644 spikes/ResultCatch/Candidate.cs create mode 100644 spikes/ResultCatch/Directory.Build.props create mode 100644 spikes/ResultCatch/Program.cs create mode 100644 spikes/ResultCatch/ResultCatch.csproj create mode 100644 spikes/ResultCatch/Trap.cs diff --git a/spikes/ResultCatch/.gitignore b/spikes/ResultCatch/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/spikes/ResultCatch/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/spikes/ResultCatch/Candidate.cs b/spikes/ResultCatch/Candidate.cs new file mode 100644 index 0000000..ada94ad --- /dev/null +++ b/spikes/ResultCatch/Candidate.cs @@ -0,0 +1,107 @@ +using CodoMetis.TypeKit; + +namespace ResultCatch; + +/// The factories as the base package would ship them on its Result static class. +public static class Candidate +{ + public static Result Catch(Func action) where T : notnull + { + try + { + return Result.Success(action()); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static Result Catch(Action action) + { + try + { + action(); + return Result.Success(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static Result Catch(Func action) + where T : notnull + where TException : Exception + { + try + { + return Result.Success(action()); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static Result Catch(Action action) + where TException : Exception + { + try + { + action(); + return Result.Success(); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + // The trap: a Task-returning delegate given to Catch binds T to the task and catches nothing + // the task throws. The overload is more specific than Func, so it wins, and it is an error. + [Obsolete("The delegate returns a Task, which Catch cannot observe: use CatchAsync.", error: true)] + public static Result, Exception> Catch(Func> action) => throw new NotSupportedException(); + + [Obsolete("The delegate returns a Task, which Catch cannot observe: use CatchAsync.", error: true)] + public static Result Catch(Func action) => throw new NotSupportedException(); + + public static async Task> CatchAsync(Func> action) where T : notnull + { + try + { + return Result.Success(await action().ConfigureAwait(false)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static async Task> CatchAsync(Func action) + { + try + { + await action().ConfigureAwait(false); + return Result.Success(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } + + public static async Task> CatchAsync(Func> action) + where T : notnull + where TException : Exception + { + try + { + return Result.Success(await action().ConfigureAwait(false)); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + } +} diff --git a/spikes/ResultCatch/Directory.Build.props b/spikes/ResultCatch/Directory.Build.props new file mode 100644 index 0000000..578e79b --- /dev/null +++ b/spikes/ResultCatch/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/spikes/ResultCatch/Program.cs b/spikes/ResultCatch/Program.cs new file mode 100644 index 0000000..ed20670 --- /dev/null +++ b/spikes/ResultCatch/Program.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using CodoMetis.TypeKit; +using ResultCatch; + +var failures = 0; + +void Claim(int number, string text, bool holds) +{ + Console.WriteLine($"{(holds ? "PASS" : "FAIL")} {number,2} {text}"); + if (!holds) failures++; +} + +static string Name(T _) => typeof(T).Name; +static string Show(Result r) where T : notnull where TError : notnull => + r.Match(v => $"Success({v})", e => $"Error({e.GetType().Name})"); +static string ShowUnit(Result r) where TError : notnull => + r.Match(() => "Success", e => $"Error({e.GetType().Name})"); +static int Throws() => throw new InvalidOperationException("boom"); +static async Task ThrowsLaterAsync() { await Task.Yield(); throw new InvalidOperationException("later"); } + +// 1 inference +var parsed = Candidate.Catch(() => int.Parse("42")); +Claim(1, $"Catch(() => int.Parse(s)) infers T: {Name(parsed)}", Name(parsed) == "Result`2" && Show(parsed) == "Success(42)"); +var refused = Candidate.Catch(() => int.Parse("x")); +Claim(1, $"and a throw is Error: {Show(refused)}", Show(refused) == "Error(FormatException)"); + +// 2 value-less +var acted = Candidate.Catch(() => Console.Write("")); +Claim(2, $"Catch(Action) is Result: {Name(acted)} {ShowUnit(acted)}", Name(acted) == "Result`1" && ShowUnit(acted) == "Success"); +Claim(2, $"and a throwing action is Error: {ShowUnit(Candidate.Catch(() => { Throws(); }))}", ShowUnit(Candidate.Catch(() => { Throws(); })) == "Error(InvalidOperationException)"); + +// 3 narrowed +var narrowed = Candidate.Catch(() => int.Parse("x")); +Claim(3, $"Catch catches that type: {Show(narrowed)}", Show(narrowed) == "Error(FormatException)"); +try +{ + Candidate.Catch(Throws); + Claim(3, "another exception propagates", false); +} +catch (InvalidOperationException e) +{ + Claim(3, $"another exception propagates with its stack intact: {e.StackTrace?.Contains("Throws")}", e.StackTrace?.Contains("Throws") == true); +} + +// 4 both type arguments spelled: Catch(...) compiles above; Catch(() => int.Parse("x")) would bind the Action overload or fail (Trap would show). Recorded in the README. +Claim(4, "Catch needs both type arguments; a one-argument Catch(Action) exists for the value-less shape", true); + +// 6 async +var later = await Candidate.CatchAsync(ThrowsLaterAsync); +Claim(6, $"CatchAsync(Task) catches after the first await: {Show(later)}", Show(later) == "Error(InvalidOperationException)"); +var laterUnit = await Candidate.CatchAsync(async () => { await Task.Yield(); Throws(); }); +Claim(6, $"CatchAsync(Task) too: {ShowUnit(laterUnit)}", ShowUnit(laterUnit) == "Error(InvalidOperationException)"); +var ok = await Candidate.CatchAsync(async () => { await Task.Yield(); return 7; }); +Claim(6, $"and a completing task is Success: {Show(ok)}", Show(ok) == "Success(7)"); + +// 7 cancellation passes through +bool CancellationPassed(Action act) { try { act(); return false; } catch (OperationCanceledException) { return true; } } +Claim(7, "sync Catch lets OperationCanceledException through", CancellationPassed(() => Candidate.Catch(int () => throw new OperationCanceledException()))); +Claim(7, "narrowed Catch lets it through too", CancellationPassed(() => Candidate.Catch(int () => throw new OperationCanceledException()))); +using var cts = new CancellationTokenSource(); +cts.Cancel(); +var asyncCancelled = false; +try { await Candidate.CatchAsync(async () => { await Task.Delay(10, cts.Token); return 1; }); } +catch (OperationCanceledException) { asyncCancelled = true; } +Claim(7, "CatchAsync lets a cancelled token through as cancellation", asyncCancelled); + +// 8 combinators unchanged +var selectorThrew = false; +try { Candidate.Catch(() => 1).Map(_ => Throws()); } catch (InvalidOperationException) { selectorThrew = true; } +Claim(8, "Map's throwing selector propagates", selectorThrew); + +// 10 the instance thrown +var thrown = new InvalidOperationException("same"); +var caught = Candidate.Catch(int () => throw thrown); +Claim(10, "the error is the instance thrown, nothing wraps it", caught.TryGetValue(out _, out var error) == false && ReferenceEquals(error, thrown)); + +// 11: a dropped Catch is reported by CMTK0003 at build time (read the build output). +Candidate.Catch(() => 1); +// 5 (the cost): a lambda that only throws has no return type, so Catch(() => throw x) is ambiguous between Func and the Func> trap; int () => throw x resolves it. + +Console.WriteLine(failures == 0 ? "ALL PASS" : $"{failures} FAILED"); +return failures; diff --git a/spikes/ResultCatch/README.md b/spikes/ResultCatch/README.md index fbecc9a..881fbc1 100644 --- a/spikes/ResultCatch/README.md +++ b/spikes/ResultCatch/README.md @@ -1,6 +1,6 @@ # Spike: can an exception enter a `Result` through one factory, without a `Try` type? -**Status: outline, not yet run.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 +**Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 (2.0.0) in [docs/plan.md](../../docs/plan.md) §2, decision 43. §9 keeps `Try` and `Unit` out: a `Try` is `Result` under a second name, and `Result` is the value-less shape a `Unit` would stand in for. @@ -16,14 +16,27 @@ returning a concrete result rather than a marker, with the combinators unchanged 3. **`CatchAsync`** for each, over `Task`, `Task` and `ValueTask`. 4. **A cancellation is not an outcome**: `OperationCanceledException` passes through every overload. -**Answer: pending.** - -## The candidate (planned) - -A console project on the packed base package: `Candidate.cs` holds the factories as the base -package would ship them, `Program.cs` runs each claim and prints the outcome, and `Trap.cs`, behind -`#if TRAP`, holds the calls that must not compile, built once with `-p:DefineConstants=TRAP` to -read the errors. An `-p:Aot=true` publish repeats the run under Native AOT. +**Answer: yes, with two shapes settled by the compiler rather than by the plan.** Measured 2026-10-13 +on .NET SDK 10.0.401 against the real base package, under JIT and published with Native AOT (no +trim or AOT warning; the AOT binary passes every claim). The async trap is an `[Obsolete(error: true)]` +overload over `Func>` and `Func`: more specific than `Func`, so the compiler picks it +for a task-returning delegate and reports CS0619 naming `CatchAsync`. Its cost is a lambda that only +throws, which has no return type: with a type argument it is ambiguous (CS0121), and without one the +compiler prefers the non-void delegate for an expression body and lands on the trap; `int () => throw …` +or `void () => throw …` resolves both, and real call sites return something. No `ValueTask` overloads: +beside `Func>` they are ambiguous (CS0121) for every `async` lambda, so a `ValueTask` takes +`.AsTask()` or an `async` lambda, as the README already says of `MapAsync`. `Catch` +spells both type arguments; a one-argument `Catch(Action)` is the command form, and a +value-returning expression lambda given to it is discarded, which the README says. Shipped in 2.0.0 +as `Result.Catch`/`Result.CatchAsync`, with `ResultCatchTests` holding each row. + +## The candidate + +A console project on the base package: `Candidate.cs` holds the factories as the base package +ships them, `Program.cs` runs each claim and prints PASS or FAIL, and `Trap.cs`, behind `-p:Trap=true`, +holds the calls that must not compile, so the build's errors are the measurement. `dotnet run` is the +JIT run, `dotnet build --no-incremental` shows CMTK0003 on the dropped call, and +`dotnet publish -c Release -p:Aot=true -o out/aot` repeats the run under Native AOT. ## Findings @@ -32,15 +45,15 @@ Each row is a claim the spike must settle. A seed that breaks the guard proves e | # | Claim | Result | |---|---|---| -| 1 | `Result.Catch(() => int.Parse(s))` infers `T`, and the result is `Result`, not a marker | | -| 2 | `Catch(Action)` returns `Result`, so no `Unit` is needed for a call without a value | | -| 3 | `Catch` catches that type only; another exception propagates with its stack intact | | -| 4 | `Catch` needs both type arguments spelled (C# infers all or none), or a shape exists that infers `T` from the delegate and takes `TException` alone | | -| 5 | The async trap: `Result.Catch(() => client.GetAsync(url))` is refused, by an `[Obsolete]` error overload over `Func`/`Func>`, by a CMTK rule, or both; `CatchAsync` is the way | | -| 6 | `CatchAsync` over `Task`, `Task` and `ValueTask` catches an exception the awaited task throws, not only one thrown before the first await | | -| 7 | `OperationCanceledException` passes through every overload, sync and async, and a cancelled token surfaces as cancellation, never as an error | | -| 8 | `Map`, `Bind` and the query-syntax `Select` are unchanged: a selector that throws propagates | | -| 9 | `ToHttpResult()` over `Result` is a compile error | | -| 10 | A caught exception is handed out by `TryGetError` and `Match` as the instance thrown; nothing wraps it | | -| 11 | CMTK0003 reports a dropped `Result.Catch(...)` as it reports any dropped result | | -| 12 | Native AOT: no new trim or AOT warning, in the packages or in the consumer | | +| 1 | `Result.Catch(() => int.Parse(s))` infers `T`, and the result is `Result`, not a marker | measured: `Result`, `Success(42)`; `int.Parse("x")` is `Error(FormatException)` | +| 2 | `Catch(Action)` returns `Result`, so no `Unit` is needed for a call without a value | measured: `Catch(Action)` is `Result`, a throwing action is its error | +| 3 | `Catch` catches that type only; another exception propagates with its stack intact | measured: `FormatException` caught; an `InvalidOperationException` propagates with `Throws` in its stack | +| 4 | `Catch` needs both type arguments spelled (C# infers all or none), or a shape exists that infers `T` from the delegate and takes `TException` alone | measured: both are spelled; no shape infers `T` alone, since C# infers all or none. The command form `Catch(Action)` takes one | +| 5 | The async trap: `Result.Catch(() => client.GetAsync(url))` is refused, by an `[Obsolete]` error overload over `Func`/`Func>`, by a CMTK rule, or both; `CatchAsync` is the way | measured: the `[Obsolete(error: true)]` overloads over `Func>` and `Func` are CS0619 for a lambda and for a method group, naming `CatchAsync`; no analyzer rule needed. Cost: a lambda that only throws needs `int () =>`/`void () =>` | +| 6 | `CatchAsync` over `Task`, `Task` and `ValueTask` catches an exception the awaited task throws, not only one thrown before the first await | measured for `Task` and `Task`: an exception after `Task.Yield()` is the error. `ValueTask` dropped: its overload is CS0121 for every `async` lambda | +| 7 | `OperationCanceledException` passes through every overload, sync and async, and a cancelled token surfaces as cancellation, never as an error | measured: a thrown `OperationCanceledException` and a cancelled `Task.Delay` both surface as cancellation, sync, narrowed and async | +| 8 | `Map`, `Bind` and the query-syntax `Select` are unchanged: a selector that throws propagates | measured: `Catch(() => 1).Map(_ => Throws())` throws | +| 9 | `ToHttpResult()` over `Result` is a compile error | measured: `Catch(() => 1).ToHttpResult()` is CS1061, the extension does not bind on `Result` | +| 10 | A caught exception is handed out by `TryGetError` and `Match` as the instance thrown; nothing wraps it | measured: `ReferenceEquals(error, thrown)` | +| 11 | CMTK0003 reports a dropped `Result.Catch(...)` as it reports any dropped result | measured: CMTK0003 on the dropped `Candidate.Catch(() => 1)` in a clean build | +| 12 | Native AOT: no new trim or AOT warning, in the packages or in the consumer | measured: `dotnet publish -p:Aot=true` with no IL warning; the binary prints ALL PASS | diff --git a/spikes/ResultCatch/ResultCatch.csproj b/spikes/ResultCatch/ResultCatch.csproj new file mode 100644 index 0000000..93e69e6 --- /dev/null +++ b/spikes/ResultCatch/ResultCatch.csproj @@ -0,0 +1,26 @@ + + + Exe + net10.0 + 14 + enable + enable + + + + true + + + + $(DefineConstants);TRAP + + + + + + + + + + + diff --git a/spikes/ResultCatch/Trap.cs b/spikes/ResultCatch/Trap.cs new file mode 100644 index 0000000..603af03 --- /dev/null +++ b/spikes/ResultCatch/Trap.cs @@ -0,0 +1,23 @@ +#if TRAP +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; + +namespace ResultCatch; + +public static class Trap +{ + static Task FetchAsync() => Task.FromResult(1); + static Task DoAsync() => Task.CompletedTask; + + // Claim 5: each of these must be an error, not a Result over a Task. + public static void AsyncTrap() + { + var a = Candidate.Catch(() => FetchAsync()); + var b = Candidate.Catch(() => DoAsync()); + var d = Candidate.Catch(FetchAsync); + } + + // Claim 9: an exception is not a fault a client acts on. + public static object HttpTrap() => Candidate.Catch(() => 1).ToHttpResult(); +} +#endif From 4dbb59270a53fa2ee4fda62d84506a95c6530192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 10:43:43 +0200 Subject: [PATCH 007/147] feat: Result.Catch and CatchAsync, the one place an exception becomes data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catch(() => …) answers Result, Catch(Action) Result, Catch catches that type only, CatchAsync awaits the task. OperationCanceledException passes through every overload; a task-returning delegate given to Catch is a compile error naming CatchAsync; a delegate that returns null throws as Result.Success(null) does. Seeded: without the cancellation filter and the trap overloads, seven tests failed. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 + docs/plan.md | 10 +- src/CodoMetis.TypeKit/README.md | 24 ++ src/CodoMetis.TypeKit/ResultExtensions.cs | 255 ++++++++++++++++++ .../Base/CatchSamples.cs | 52 ++++ .../NullDelegateTests.cs | 45 +++- .../ResultCatchTests.cs | 190 +++++++++++++ 7 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs create mode 100644 test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ac40e40..e002daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ Semantic Versioning reserves for a major, listed under Changed as they land. ### Added +- **`CodoMetis.TypeKit`**: `Result.Catch`, the one place an exception becomes data. `Catch(() => …)` + runs the call and answers `Result`, or `Result` for an action; + `Catch` catches that type only and lets any other propagate; `CatchAsync` awaits the + task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` + passes through every overload, a delegate that returns a `Task` given to `Catch` is a compile error + that names `CatchAsync`, and a delegate that returns null throws as `Result.Success(null)` does. - **`CodoMetis.TypeKit`**: the text guards. `text.AsText()` is for text a person wrote: trimmed, every space separator folded to an ordinary space, composed to NFC so one name has one spelling, and refused when it carries a character that renders as nothing, such as a zero-width space, a Hangul diff --git a/docs/plan.md b/docs/plan.md index 2a91c7f..9f9736d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -551,8 +551,14 @@ Each phase ends green, and its guards have been proven by seeding the defect 2. **The text guards** ✅ 2026-10-13: `StringTextExtensions` and `TextFault` in the base package, `StringTextExtensionsTests` (the character table, pinned by code point), the README section "Written text" with its two compiled samples. No spike. - 3. **`Result.Catch`**, after [spikes/ResultCatch](../spikes/ResultCatch/README.md), since the - async trap can change the shape. + 3. **`Result.Catch`** ✅ 2026-10-13, after [spikes/ResultCatch](../spikes/ResultCatch/README.md). + The spike settled the shape: the async trap is an `[Obsolete(error: true)]` overload over a + task-returning delegate, more specific than `Func`, so the compiler refuses it and names + `CatchAsync`; its cost is that a lambda that only throws must state its return type. No + `ValueTask` overloads: beside `Func>` they are ambiguous for every `async` lambda, as the + README already says of `MapAsync`. `Catch` spells both type arguments, and the + one-argument `Catch(Action)` is the command form. A delegate that returns null throws + rather than being caught: the value is computed inside the `try`, the success built outside it. 4. **CMTK0011 and its code fix**: pure Roslyn, no spike. 5. **CMTK0010**, after [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md), since the forwarding claim decides whether the rule is sound. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index b6dffbb..2b4796b 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -147,6 +147,30 @@ returns, and what it throws reaches that `await`. A callback that returns a `Val `.AsTask()`, or an `async` lambda: `TapAsync` and `TapErrorAsync` would bind it to their synchronous overload and not wait for it, and `MapAsync` would hold the `ValueTask` as the value. +**Exceptions as outcomes.** A call that throws has no way into a `Result` except a `try`/`catch` +written by hand. `Result.Catch` runs the call and answers its value, or the exception as the error: +the one place an exception becomes data. + +```csharp +Result parsed = Result.Catch(() => int.Parse("42")); // Success: 42 +Result refused = Result.Catch(() => int.Parse("x")); // Error: FormatException +Result narrowed = Result.Catch(() => int.Parse("x")); // only that type; any other propagates +Result copied = Result.Catch(() => File.Copy(source, target)); // a command: a success, or the exception +Result fetched = await Result.CatchAsync(() => client.GetByteArrayAsync(url)); // waits for the task; its exception is the error +``` + +`Catch` catches that type only and lets every other exception propagate, so the error +type says what was expected to fail; both type arguments are spelled, since C# infers all or none, +and `Catch(() => int.Parse(s))` with one is the command form, which discards the +number. `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error +too. An `OperationCanceledException` passes through all of them: a cancellation is not an outcome, +and caught it would read as a failure that `Recover` then runs on. A delegate that returns a `Task` +given to `Catch` is a compile error, since the task's exception would be thrown after `Catch` had +returned; a `ValueTask` needs `.AsTask()` or an `async` lambda, as above. A delegate that returns +null throws, as `Result.Success(null)` does. `Map`, `Bind` and the rest are unchanged: a selector +that throws propagates. An exception is for the code that called, not for a client: `ToHttpResult()` +does not accept a `Result`, and `MapError` turns the exception into a fault that it does. + ## Not wire types Neither `Option` nor `Result` serializes. System.Text.Json refuses both, in both directions, with a diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 895bd79..24cbeeb 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -31,6 +31,261 @@ public static Success Success(T value) where T : notnull => public static Error Error(T error) where T : notnull => error is null ? throw ThrowHelper.NullContent(nameof(error)) : new(error); + /// + /// Runs and returns its value as a success, or the exception it threw + /// as the error. The one place an exception becomes data. + /// + /// + /// + /// An passes through: a cancellation is not an outcome, + /// and caught it would read as a failure and Recover would run on it. A delegate that + /// returns a is refused at compile time, since the task's exception is thrown + /// after this call returns; waits for it. + /// + /// + /// A delegate that returns null throws , as + /// does: a success over null is a defect of the delegate, not an outcome, and is not caught here. + /// + /// + /// The call whose outcome is wanted. + /// The type of the value. + /// A success holding the value, or an error holding the exception thrown. + /// is null, or returned null. + public static Result Catch(Func action) where T : notnull + { + ArgumentNullException.ThrowIfNull(action); + + T value; + try + { + value = action(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(value); + } + + /// + /// Runs and returns a success, or the exception it threw as the error. + /// + /// + /// An passes through, and a delegate that returns a + /// is refused at compile time: see . + /// + /// The call whose outcome is wanted. + /// A success, or an error holding the exception thrown. + /// is null. + public static Result Catch(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + try + { + action(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(); + } + + /// + /// Runs and returns its value as a success, or the + /// it threw as the error. Any other exception propagates. + /// + /// + /// Both type arguments are spelled, Result.Catch<int, FormatException>(() => int.Parse(s)), + /// since C# infers all of a method's type arguments or none. The error type then says what was + /// expected to fail. See for cancellation, tasks and null. + /// + /// The call whose outcome is wanted. + /// The type of the value. + /// The exception that is an outcome; any other propagates. + /// A success holding the value, or an error holding the exception thrown. + /// is null, or returned null. + public static Result Catch(Func action) + where T : notnull + where TException : Exception + { + ArgumentNullException.ThrowIfNull(action); + + T value; + try + { + value = action(); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(value); + } + + /// + /// Runs and returns a success, or the + /// it threw as the error. Any other exception propagates. + /// + /// + /// A lambda with an expression body converts to an and its value is + /// discarded, so Result.Catch<FormatException>(() => int.Parse(s)) is this overload + /// and answers a without the number; the valued form spells both + /// type arguments. See for cancellation and tasks. + /// + /// The call whose outcome is wanted. + /// The exception that is an outcome; any other propagates. + /// A success, or an error holding the exception thrown. + /// is null. + public static Result Catch(Action action) + where TException : Exception + { + ArgumentNullException.ThrowIfNull(action); + + try + { + action(); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(); + } + + /// Refused: the task's exception is thrown after Catch has returned. Use . + /// + /// More specific than for a delegate returning a task, so the + /// compiler picks it and reports the error. A lambda that only throws has no return type and + /// lands here, or is ambiguous with a type argument; int () => throw … states it. + /// + /// A delegate returning a task. + /// The type of the task's value. + /// Never: the overload does not compile. + [Obsolete("The delegate returns a Task, whose exception is thrown after Catch has returned. Use CatchAsync.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static Result, Exception> Catch(Func> action) => throw new NotSupportedException(); + + /// Refused: the task's exception is thrown after Catch has returned. Use . + /// A delegate returning a task. + /// Never: the overload does not compile. + [Obsolete("The delegate returns a Task, whose exception is thrown after Catch has returned. Use CatchAsync.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static Result Catch(Func action) => throw new NotSupportedException(); + + /// + /// Awaits and returns its value as a success, or the exception the + /// task threw as the error, whether before or after its first await. + /// + /// + /// An passes through, so a cancelled token surfaces as + /// cancellation. A delegate returning a ValueTask needs .AsTask() or an async + /// lambda: a ValueTask overload beside this one is ambiguous for every async lambda. + /// See for null. + /// + /// The asynchronous call whose outcome is wanted. + /// The type of the value. + /// A success holding the value, or an error holding the exception thrown. + /// is null, or its task produced null. + public static async Task> CatchAsync(Func> action) where T : notnull + { + ArgumentNullException.ThrowIfNull(action); + + T value; + try + { + value = await action().ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(value); + } + + /// + /// Awaits and returns a success, or the exception the task threw as + /// the error. See . + /// + /// The asynchronous call whose outcome is wanted. + /// A success, or an error holding the exception thrown. + /// is null. + public static async Task> CatchAsync(Func action) + { + ArgumentNullException.ThrowIfNull(action); + + try + { + await action().ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(); + } + + /// + /// Awaits and returns its value as a success, or the + /// the task threw as the error. Any other exception propagates. + /// See and . + /// + /// The asynchronous call whose outcome is wanted. + /// The type of the value. + /// The exception that is an outcome; any other propagates. + /// A success holding the value, or an error holding the exception thrown. + /// is null, or its task produced null. + public static async Task> CatchAsync(Func> action) + where T : notnull + where TException : Exception + { + ArgumentNullException.ThrowIfNull(action); + + T value; + try + { + value = await action().ConfigureAwait(false); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(value); + } + + /// + /// Awaits and returns a success, or the + /// the task threw as the error. Any other exception propagates. See . + /// + /// The asynchronous call whose outcome is wanted. + /// The exception that is an outcome; any other propagates. + /// A success, or an error holding the exception thrown. + /// is null. + public static async Task> CatchAsync(Func action) + where TException : Exception + { + ArgumentNullException.ThrowIfNull(action); + + try + { + await action().ConfigureAwait(false); + } + catch (TException exception) when (exception is not OperationCanceledException) + { + return Result.Error(exception); + } + + return Result.Success(); + } + /// The result. /// The type of the value. /// The type of the error. diff --git a/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs new file mode 100644 index 0000000..7eaecc4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs @@ -0,0 +1,52 @@ +using CodoMetis.TypeKit; + +namespace ReadmeSamples.Base.Catching; + +public static class Outcomes +{ + public static async Task RunAsync(string source, string target, HttpClient client, Uri url) + { + // sample: CodoMetis.TypeKit/catch + Result parsed = Result.Catch(() => int.Parse("42")); // Success: 42 + Result refused = Result.Catch(() => int.Parse("x")); // Error: FormatException + Result narrowed = Result.Catch(() => int.Parse("x")); // only that type; any other propagates + Result copied = Result.Catch(() => File.Copy(source, target)); // a command: a success, or the exception + Result fetched = await Result.CatchAsync(() => client.GetByteArrayAsync(url)); // waits for the task; its exception is the error + // end sample + + _ = (parsed, refused, narrowed, copied, fetched); + } +} + +public sealed class CatchTests +{ + private const string Readme = "src/CodoMetis.TypeKit/README.md"; + + private static string Render(Result result) where T : notnull => + result.Match(value => $"Success: {value}", error => $"Error: {error.GetType().Name}"); + + [Fact] + public void Each_line_answers_what_its_comment_states() + { + Result.Catch(() => int.Parse("42")).ShouldBeShownIn(Readme, Render); + Result.Catch(() => int.Parse("x")).ShouldBeShownIn(Readme, Render); + } + + [Fact] + public async Task The_sample_runs_as_written() + { + var source = Path.GetTempFileName(); + var target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromMilliseconds(100) }; + await Outcomes.RunAsync(source, target, client, new Uri("http://127.0.0.1:1/unreachable")); + File.Exists(target).ShouldBeTrue(); + } + finally + { + File.Delete(source); + File.Delete(target); + } + } +} diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index 4784056..5c5f09b 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -70,6 +70,17 @@ public sealed class NullDelegateTests ["Option.FirstOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.FirstOrNone(Null>()), () => Array.Empty().FirstOrNone(Null>())], ["Option.LastOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.LastOrNone(Null>()), () => Array.Empty().LastOrNone(Null>())], + // Catch has no branches: the delegate is checked before anything runs. Overloads that differ + // only in type parameters are keyed with them. + ["Result.Catch(Func): action"] = [() => Result.Catch(Null>())], + ["Result.Catch(Action): action"] = [() => Result.Catch(Null())], + ["Result.Catch(Func): action"] = [() => Result.Catch(Null>())], + ["Result.Catch(Action): action"] = [() => Result.Catch(Null())], + ["Result.CatchAsync(Func>): action"] = [() => Result.CatchAsync(Null>>())], + ["Result.CatchAsync(Func): action"] = [() => Result.CatchAsync(Null>())], + ["Result.CatchAsync(Func>): action"] = [() => Result.CatchAsync(Null>>())], + ["Result.CatchAsync(Func): action"] = [() => Result.CatchAsync(Null>())], + ["Result.Match(Func, Func): onSuccess"] = [() => Ok.Match(Null>(), _ => 0), () => Failed.Match(Null>(), _ => 0)], ["Result.Match(Func, Func): onError"] = [() => Ok.Match(() => 0, Null>()), () => Failed.Match(() => 0, Null>())], ["Result.Match(Func, Func): onSuccess"] = [() => Ok.Match(Null>(), () => 0), () => Failed.Match(Null>(), () => 0)], @@ -216,15 +227,31 @@ public void Every_delegate_parameter_of_the_assembly_has_a_case() Cases.Keys.Except(parameters).ShouldBeEmpty("These cases name no public method of the assembly."); } - private static IEnumerable DelegateParameters() => - from type in typeof(Option<>).Assembly.GetExportedTypes() - // The compiler's grouping types for extension blocks carry skeleton members; the callable - // methods are the static ones on the enclosing class. - where !type.Name.Contains('<') && type.GetCustomAttribute() is null - from method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) - from parameter in method.GetParameters() - where typeof(Delegate).IsAssignableFrom(parameter.ParameterType) - select $"{Describe(type)}.{method.Name}({string.Join(", ", method.GetParameters().Select(p => Describe(p.ParameterType)))}): {parameter.Name}"; + private static IEnumerable DelegateParameters() + { + var methods = + from type in typeof(Option<>).Assembly.GetExportedTypes() + // The compiler's grouping types for extension blocks carry skeleton members; the callable + // methods are the static ones on the enclosing class. + where !type.Name.Contains('<') && type.GetCustomAttribute() is null + from method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + // An overload that exists to be a compile error (Catch over a Task) cannot be called. + where method.GetCustomAttribute() is not { IsError: true } + select (Type: type, Method: method, Signature: $"({string.Join(", ", method.GetParameters().Select(p => Describe(p.ParameterType)))})"); + + // Overloads that differ only in their type parameters (Catch beside Catch) + // are keyed with them; every other method keeps its bare name. + var byKey = methods.ToLookup(m => $"{Describe(m.Type)}.{m.Method.Name}{m.Signature}"); + + return from overloads in byKey + from m in overloads + let name = overloads.Count() > 1 && m.Method.IsGenericMethod + ? $"{m.Method.Name}<{string.Join(", ", m.Method.GetGenericArguments().Select(a => a.Name))}>" + : m.Method.Name + from parameter in m.Method.GetParameters() + where typeof(Delegate).IsAssignableFrom(parameter.ParameterType) + select $"{Describe(m.Type)}.{name}{m.Signature}: {parameter.Name}"; + } private static string Describe(Type type) => type.IsByRef ? Describe(type.GetElementType()!) diff --git a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs new file mode 100644 index 0000000..aac6cf9 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs @@ -0,0 +1,190 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// Result.Catch: the one place an exception becomes data. Measured first in +/// spikes/ResultCatch; each claim there is a test here. +/// +public sealed class ResultCatchTests +{ + private static readonly InvalidOperationException Boom = new("boom"); + + private static int Throws() => throw Boom; + + private static async Task ThrowsLaterAsync() + { + await Task.Yield(); + throw Boom; + } + + [Fact] + public void Catch_infers_the_value_type_and_answers_the_value() + { + Result parsed = Result.Catch(() => int.Parse("42")); + + parsed.TryGetValue(out var value, out _).ShouldBeTrue(); + value.ShouldBe(42); + } + + [Fact] + public void Catch_answers_the_exception_thrown_as_the_instance_thrown() + { + var caught = Result.Catch(int () => throw Boom); + + caught.TryGetValue(out _, out var error).ShouldBeFalse(); + ReferenceEquals(error, Boom).ShouldBeTrue("nothing wraps the exception"); + } + + [Fact] + public void Catch_of_an_action_is_the_value_less_result() + { + Result acted = Result.Catch(() => { }); + ((bool)acted).ShouldBeTrue(); + + Result failed = Result.Catch(() => { Throws(); }); + failed.TryGetError(out var error).ShouldBeTrue(); + error.ShouldBeSameAs(Boom); + } + + [Fact] + public void A_narrowed_Catch_catches_that_type_and_lets_any_other_propagate() + { + Result refused = Result.Catch(() => int.Parse("x")); + refused.TryGetValue(out _, out var format).ShouldBeFalse(); + format.ShouldBeOfType(); + + var other = Should.Throw(() => Result.Catch(Throws)); + other.ShouldBeSameAs(Boom); + other.StackTrace!.ShouldContain(nameof(Throws), Case.Sensitive, "the stack is the original throw's"); + + Result failedCommand = Result.Catch(() => { int.Parse("x"); }); + failedCommand.TryGetError(out _).ShouldBeTrue(); + Should.Throw(() => Result.Catch(() => { Throws(); })); + } + + /// + /// A lambda with an expression body converts to , so the one-type-argument + /// form over a value-returning call is the value-less overload, and the value is discarded. The + /// valued form spells both type arguments; the README says so. + /// + [Fact] + public void A_one_type_argument_Catch_over_a_value_is_the_value_less_overload() + { + var bound = Result.Catch(() => int.Parse("42")); + + bound.ShouldBeOfType>(); + } + + [Fact] + public void A_cancellation_is_not_an_outcome() + { + Should.Throw(() => Result.Catch(int () => throw new OperationCanceledException())); + Should.Throw(() => Result.Catch(void () => throw new OperationCanceledException())); + Should.Throw(() => Result.Catch(int () => throw new OperationCanceledException())); + Should.Throw(() => Result.Catch(void () => throw new OperationCanceledException())); + } + + [Fact] + public async Task CatchAsync_catches_an_exception_thrown_after_the_first_await() + { + var later = await Result.CatchAsync(ThrowsLaterAsync); + later.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeSameAs(Boom); + + var command = await Result.CatchAsync(async () => { await Task.Yield(); Throws(); }); + command.TryGetError(out _).ShouldBeTrue(); + + var narrowed = await Result.CatchAsync(ThrowsLaterAsync); + narrowed.TryGetValue(out _, out var typed).ShouldBeFalse(); + typed.ShouldBeSameAs(Boom); + + var narrowedCommand = await Result.CatchAsync(async () => { await Task.Yield(); Throws(); }); + narrowedCommand.TryGetError(out _).ShouldBeTrue(); + + var completed = await Result.CatchAsync(async () => { await Task.Yield(); return 7; }); + completed.TryGetValue(out var value, out _).ShouldBeTrue(); + value.ShouldBe(7); + } + + [Fact] + public async Task CatchAsync_lets_a_cancelled_token_through_as_cancellation() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Delay(10, cts.Token); return 1; })); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.Delay(10, cts.Token))); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Delay(10, cts.Token); return 1; })); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.Delay(10, cts.Token))); + } + + /// A success over null is the delegate's defect, not an outcome, and is not caught. + [Fact] + public async Task A_delegate_that_returns_null_is_refused_rather_than_caught() + { + Should.Throw(() => Result.Catch(() => (string)null!)); + Should.Throw(() => Result.Catch(() => null!)); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.FromResult((string)null!))); + await Should.ThrowAsync(() => Result.CatchAsync(() => Task.FromResult((string)null!))); + } + + [Fact] + public void The_combinators_are_unchanged_so_a_throwing_selector_propagates() + { + Should.Throw(() => Result.Catch(() => 1).Map(_ => Throws())); + Should.Throw(() => Result.Catch(() => 1).Bind(_ => Throws())); + } + + /// + /// A delegate returning a task given to Catch would bind T to the task and catch + /// nothing it throws. The overload over a task-returning delegate is more specific, so the + /// compiler picks it, and it is an error that names CatchAsync. + /// + [Theory] + [InlineData("Result.Catch(() => FetchAsync())")] + [InlineData("Result.Catch(FetchAsync)")] + [InlineData("Result.Catch(() => DoAsync())")] + [InlineData("Result.Catch(DoAsync)")] + public void A_task_returning_delegate_given_to_Catch_is_a_compile_error_naming_CatchAsync(string call) + { + var compilation = ConsumerCompilation.Of($$""" + using System.Threading.Tasks; + using CodoMetis.TypeKit; + + static class Consumer + { + static Task FetchAsync() => Task.FromResult(1); + static Task DoAsync() => Task.CompletedTask; + static object Call() => {{call}}; + } + """); + + compilation.ErrorIds().ShouldBe(["CS0619"], compilation.Problems()); + compilation.Problems().ShouldContain("CatchAsync"); + } + + /// + /// The cost of the trap: a lambda that only throws has no return type. With a type argument it is + /// ambiguous between Func<T> and the trap's Func<Task<T>>; without + /// one, the compiler prefers the non-void delegate for an expression body and lands on the trap. + /// Stating the return type resolves both, and the call sites of real code have one. + /// + [Theory] + [InlineData("Result.Catch(() => throw new Exception())", "CS0121")] + [InlineData("Result.Catch(int () => throw new Exception())", "")] + [InlineData("Result.Catch(() => throw new Exception())", "CS0619")] + [InlineData("Result.Catch(void () => throw new Exception())", "")] + public void A_lambda_that_only_throws_states_its_return_type(string call, string error) + { + var compilation = ConsumerCompilation.Of($$""" + using System; + using CodoMetis.TypeKit; + + static class Consumer + { + static object Call() => {{call}}; + } + """); + + compilation.ErrorIds().ShouldBe(error.Length == 0 ? [] : [error], compilation.Problems()); + } +} From 5407327484401d8a07b38bd5dd520263857d6301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:05:22 +0200 Subject: [PATCH 008/147] feat: [RequireParameterlessConstructor] and CMTK0011, with the package's first code fix An attribute on an interface or base class, and an analyzer rule on every concrete type under it, directly or through another: a parameterless constructor of the given accessibility, or an error where the host would have failed by reflection. A class with no constructor satisfies Public; a missing constructor is reported at the type, a wrong accessibility at the constructor. The fix writes or changes it, and skips a type with a primary constructor. The fix is its own assembly, nested in the analyzer project and packed beside it (RS1038). Seeded: the sample without its pragma fails the build twice; the check disabled fails four tests. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 + CodoMetis.TypeKit.slnx | 1 + Directory.Packages.props | 3 +- README.md | 2 +- docs/plan.md | 13 +- .../AnalyzerReleases.Unshipped.md | 5 + ...doMetis.TypeKit.Analyzers.CodeFixes.csproj | 26 ++ ...ParameterlessConstructorCodeFixProvider.cs | 127 +++++++++ .../CodoMetis.TypeKit.Analyzers.csproj | 12 + .../DiagnosticIds.cs | 4 + src/CodoMetis.TypeKit.Analyzers/README.md | 34 ++- ...equiredParameterlessConstructorAnalyzer.cs | 100 +++++++ .../TypeKitSymbols.cs | 62 ++++- .../ConstructorAccessibility.cs | 27 ++ src/CodoMetis.TypeKit/README.md | 5 +- ...equireParameterlessConstructorAttribute.cs | 28 ++ .../AnalyzerTest.cs | 17 ++ .../CodoMetis.TypeKit.Analyzers.Tests.csproj | 2 + ...edParameterlessConstructorAnalyzerTests.cs | 244 ++++++++++++++++++ .../Analyzers/RequiredConstructorSamples.cs | 24 ++ 20 files changed, 735 insertions(+), 9 deletions(-) create mode 100644 src/CodoMetis.TypeKit.Analyzers/CodeFixes/CodoMetis.TypeKit.Analyzers.CodeFixes.csproj create mode 100644 src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs create mode 100644 src/CodoMetis.TypeKit.Analyzers/RequiredParameterlessConstructorAnalyzer.cs create mode 100644 src/CodoMetis.TypeKit/ConstructorAccessibility.cs create mode 100644 src/CodoMetis.TypeKit/RequireParameterlessConstructorAttribute.cs create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Analyzers/RequiredConstructorSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e002daa..6099562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ Semantic Versioning reserves for a major, listed under Changed as they land. task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through every overload, a delegate that returns a `Task` given to `Catch` is a compile error that names `CatchAsync`, and a delegate that returns null throws as `Result.Success(null)` does. +- **`CodoMetis.TypeKit.Analyzers`**: CMTK0011, with the package's first code fix. A type marked + `[RequireParameterlessConstructor]` (new in the base package, with `ConstructorAccessibility`) + requires every concrete type that derives from or implements it, directly or through another, to + have a parameterless constructor of the given accessibility, for the host that creates such types + by reflection. A class that declares no constructor satisfies `Public` as it is; a missing + constructor is reported at the type and the fix adds it, a wrong accessibility at the constructor + and the fix changes it; a type with a primary constructor gets no fix. The code fixes ship in + `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` inside the analyzer package. - **`CodoMetis.TypeKit`**: the text guards. `text.AsText()` is for text a person wrote: trimmed, every space separator folded to an ordinary space, composed to NFC so one name has one spelling, and refused when it carries a character that renders as nothing, such as a zero-width space, a Hangul diff --git a/CodoMetis.TypeKit.slnx b/CodoMetis.TypeKit.slnx index 752bec8..a15dc37 100644 --- a/CodoMetis.TypeKit.slnx +++ b/CodoMetis.TypeKit.slnx @@ -2,6 +2,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 1f4e81c..6a3f8b8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,11 +30,12 @@ - + + + + netstandard2.0 + disable + true + true + false + CodoMetis.TypeKit.Analyzers + + + + + + + + diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs new file mode 100644 index 0000000..54ca1bc --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs @@ -0,0 +1,127 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// The fix for CMTK0011: writes the parameterless constructor the type lacks, or changes the +/// accessibility of the one it declares. +/// +/// +/// A type with a primary constructor gets no fix: every other constructor of such a type chains to +/// the primary one, and the arguments to chain with are the author's to choose. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RequiredParameterlessConstructorCodeFixProvider))] +[Shared] +public sealed class RequiredParameterlessConstructorCodeFixProvider : CodeFixProvider +{ + /// The rule this fixes; held to the analyzer's id by its tests. + public const string DiagnosticId = "CMTK0011"; + + /// The diagnostic property carrying the required accessibility, as spells it; held to the analyzer's by its tests. + public const string RequiredProperty = "Required"; + + /// + public override ImmutableArray FixableDiagnosticIds => [DiagnosticId]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + if (!diagnostic.Properties.TryGetValue(RequiredProperty, out var requiredName) + || !System.Enum.TryParse(requiredName, out var required)) + continue; + + var node = root.FindNode(diagnostic.Location.SourceSpan); + var spelling = Spell(required); + + if (node.FirstAncestorOrSelf() is { } constructor) + { + context.RegisterCodeFix( + CodeAction.Create( + $"Make the constructor {spelling}", + cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(constructor, WithAccessibility(constructor, required)))), + equivalenceKey: $"{DiagnosticId}:change"), + diagnostic); + } + else if (node.FirstAncestorOrSelf() is { ParameterList: null } type) + { + context.RegisterCodeFix( + CodeAction.Create( + $"Add a {spelling} parameterless constructor", + cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(type, WithConstructor(type, required)))), + equivalenceKey: $"{DiagnosticId}:add"), + diagnostic); + } + } + } + + /// Only the modifiers change; the rest of the declaration keeps its own layout. + private static ConstructorDeclarationSyntax WithAccessibility(ConstructorDeclarationSyntax constructor, Accessibility required) + { + var leading = constructor.Modifiers.Count > 0 ? constructor.Modifiers[0].LeadingTrivia : constructor.GetLeadingTrivia(); + var kept = constructor.Modifiers.Where(modifier => !IsAccessibility(modifier)).Select(modifier => modifier.WithLeadingTrivia()); + var replaced = Modifiers(required).Select(token => token.WithTrailingTrivia(SyntaxFactory.Space)).Concat(kept).ToArray(); + + replaced[0] = replaced[0].WithLeadingTrivia(leading); + + return constructor.Modifiers.Count > 0 + ? constructor.WithModifiers(SyntaxFactory.TokenList(replaced)) + : constructor.WithModifiers(SyntaxFactory.TokenList(replaced)).WithIdentifier(constructor.Identifier.WithLeadingTrivia()); + } + + private static TypeDeclarationSyntax WithConstructor(TypeDeclarationSyntax type, Accessibility required) + { + var constructor = SyntaxFactory.ConstructorDeclaration(type.Identifier.WithoutTrivia()) + .WithModifiers(SyntaxFactory.TokenList(Modifiers(required))) + .WithBody(SyntaxFactory.Block()) + .WithAdditionalAnnotations(Formatter.Annotation); + + // A declaration ending in ';' (a record with no body) gets braces to hold the constructor. + var withBody = type.SemicolonToken.IsKind(SyntaxKind.SemicolonToken) + ? type.WithSemicolonToken(default).WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)).WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + : type; + + return withBody.WithMembers(withBody.Members.Insert(0, constructor)).WithAdditionalAnnotations(Formatter.Annotation); + } + + private static string Spell(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "private", + }; + + private static bool IsAccessibility(SyntaxToken token) => + token.IsKind(SyntaxKind.PublicKeyword) || token.IsKind(SyntaxKind.InternalKeyword) + || token.IsKind(SyntaxKind.ProtectedKeyword) || token.IsKind(SyntaxKind.PrivateKeyword); + + private static SyntaxToken[] Modifiers(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => [SyntaxFactory.Token(SyntaxKind.PublicKeyword)], + Accessibility.Internal => [SyntaxFactory.Token(SyntaxKind.InternalKeyword)], + Accessibility.Protected => [SyntaxFactory.Token(SyntaxKind.ProtectedKeyword)], + Accessibility.ProtectedOrInternal => [SyntaxFactory.Token(SyntaxKind.ProtectedKeyword), SyntaxFactory.Token(SyntaxKind.InternalKeyword)], + Accessibility.ProtectedAndInternal => [SyntaxFactory.Token(SyntaxKind.PrivateKeyword), SyntaxFactory.Token(SyntaxKind.ProtectedKeyword)], + _ => [SyntaxFactory.Token(SyntaxKind.PrivateKeyword)], + }; +} diff --git a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj index 3b17cf4..be18670 100644 --- a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj +++ b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj @@ -30,6 +30,18 @@ + + + + + + + + + + diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs new file mode 100644 index 0000000..c46a970 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs @@ -0,0 +1,244 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0011: a concrete type under [RequireParameterlessConstructor] without the constructor +/// it requires. The host would find out by reflection, at run time; this rule says so at the type. +/// +public sealed class RequiredParameterlessConstructorAnalyzerTests +{ + /// + /// The marked types, present in every test: an interface and an abstract class carry the + /// requirement and are never reported themselves, and a derived interface forwards it. + /// + private const string Subjects = + """ + using CodoMetis.TypeKit; + + namespace Subjects + { + [RequireParameterlessConstructor] + public interface IEndpoint { } + + public interface IQueryEndpoint : IEndpoint { } + + [RequireParameterlessConstructor(ConstructorAccessibility.Internal)] + public abstract class Module { } + + [RequireParameterlessConstructor(ConstructorAccessibility.Private)] + public interface IActivated { } + } + + """; + + private static AnalyzerTest Test(string declarations, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(Subjects + declarations); + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static DiagnosticResult Cmtk0011(string type, string state, string source, string required) => + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(0).WithArguments(type, state, source, required); + + [Fact] + public void The_descriptor_is_the_published_contract() + { + var rule = new RequiredParameterlessConstructorAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + rule.Id.ShouldBe("CMTK0011"); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); + rule.IsEnabledByDefault.ShouldBeTrue(); + } + + /// The fix lives in another assembly, so the id and the property name it reads are held to the analyzer's here. + [Fact] + public void The_code_fix_names_the_rule_and_the_property_the_analyzer_writes() + { + new RequiredParameterlessConstructorCodeFixProvider().FixableDiagnosticIds.ShouldBe(["CMTK0011"]); + RequiredParameterlessConstructorCodeFixProvider.RequiredProperty.ShouldBe(RequiredParameterlessConstructorAnalyzer.RequiredProperty); + } + + [Fact] + public Task A_class_without_any_constructor_has_the_public_one_and_is_silent() => + Test("public sealed class Orders : Subjects.IEndpoint { }").RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_class_with_only_a_parameterised_constructor_reports_on_its_name() => + Test( + """ + public sealed class {|#0:Orders|} : Subjects.IEndpoint + { + public Orders(int port) { } + } + """, + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'Orders' declares no parameterless constructor, but IEndpoint requires one that is public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_wrong_accessibility_reports_on_the_constructor() => + Test( + """ + public sealed class Orders : Subjects.IEndpoint + { + private {|#0:Orders|}() { } + } + """, + Cmtk0011("Orders", "declares a private parameterless constructor", "IEndpoint", "public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_implicit_public_constructor_does_not_satisfy_a_non_public_requirement() => + Test( + "public sealed class {|#0:Identity|} : Subjects.Module { }", + Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_required_accessibility_is_the_attribute_s() => + Test( + """ + public sealed class Identity : Subjects.Module + { + internal Identity() { } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_requirement_reaches_through_a_derived_interface_and_a_base_class() => + Test( + """ + public abstract class EndpointBase : Subjects.IQueryEndpoint { } + + public sealed class {|#0:Orders|} : EndpointBase + { + public Orders(int port) { } + } + """, + Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_record_with_a_primary_constructor_has_no_parameterless_one() => + Test( + "public sealed record {|#0:Orders|}(int Port) : Subjects.IEndpoint;", + Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_struct_has_the_public_constructor_and_is_silent_for_public() => + Test("public struct Ping : Subjects.IEndpoint { }").RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Two_requirements_of_different_accessibility_each_report_once() => + Test( + """ + public sealed class {|#0:Both|} : Subjects.Module, Subjects.IActivated + { + public Both(int x) { } + } + """, + Cmtk0011("Both", "declares no parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(0).WithArguments("Both", "declares no parameterless constructor", "IActivated", "private")) + .RunAsync(TestContext.Current.CancellationToken); + + /// The subjects alone: the marked interface and abstract class are never reported. + [Fact] + public Task The_marked_types_themselves_are_silent() => + Test("").RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_type_under_no_marked_type_is_left_alone() => + Test( + """ + public sealed class Free + { + private Free() { } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// Robustness, not a guard: without CodoMetis.TypeKit there is nothing to look at, and nothing may fail. + [Fact] + public Task A_compilation_without_TypeKit_is_left_alone() => + new AnalyzerTest("public struct S { }", referenceTypeKit: false) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_adds_the_constructor_a_class_lacks() => + new CodeFixTest( + Subjects + """ + public sealed class {|#0:Orders|} : Subjects.IEndpoint + { + public Orders(int port) { } + } + """, + Subjects + """ + public sealed class Orders : Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_changes_a_wrong_accessibility_in_place() => + new CodeFixTest( + Subjects + """ + public sealed class Identity : Subjects.Module + { + public {|#0:Identity|}() { } + } + """, + Subjects + """ + public sealed class Identity : Subjects.Module + { + internal Identity() { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Identity", "declares a public parameterless constructor", "Module", "internal") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_writes_the_non_public_constructor_a_class_only_has_implicitly() => + new CodeFixTest( + Subjects + "public sealed class {|#0:Identity|} : Subjects.Module { }", + Subjects + """ + public sealed class Identity : Subjects.Module + { + internal Identity() + { + } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// Every other constructor of a type with a primary constructor chains to it, with arguments only the author knows. + [Fact] + public Task A_type_with_a_primary_constructor_gets_no_fix() => + new CodeFixTest( + Subjects + "public sealed record {|#0:Orders|}(int Port) : Subjects.IEndpoint;", + Subjects + "public sealed record {|#0:Orders|}(int Port) : Subjects.IEndpoint;") + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + FixedState = { ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") } }, + } + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/RequiredConstructorSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/RequiredConstructorSamples.cs new file mode 100644 index 0000000..2f433eb --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/RequiredConstructorSamples.cs @@ -0,0 +1,24 @@ +using CodoMetis.TypeKit; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Analyzers.RequiredConstructor; + +#pragma warning disable CMTK0011 // what the sample shows the rule reporting +#pragma warning disable CS9113 // the primary constructor's parameter is the point, and unused +// sample: CodoMetis.TypeKit.Analyzers/cmtk0011 +[RequireParameterlessConstructor] +public interface IEndpoint { void Map(IEndpointRouteBuilder app); } + +[RequireParameterlessConstructor(ConstructorAccessibility.Internal)] +public abstract class Module { } + +public sealed class OrderEndpoint(ILogger log) : IEndpoint // CMTK0011: a primary constructor, so no parameterless one +{ + public void Map(IEndpointRouteBuilder app) { } +} + +public sealed class Identity : Module; // CMTK0011: the implicit constructor is public, Module requires internal +// end sample +#pragma warning restore CS9113 +#pragma warning restore CMTK0011 From b04c40f164a2ffe2c5f16d6c29f73f84429127e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:12:26 +0200 Subject: [PATCH 009/147] spike: TypeArgumentConstraints, run and answered: attributes constrain type arguments where C# cannot The real rule over a library, a consumer with one seeded breach per claim and a Razor component: every seeded line reported with its count, nothing else; a forwarding type parameter is a breach unless it carries the attribute or struct/new() proves it. Co-Authored-By: Claude Fable 5.1 --- spikes/TypeArgumentConstraints/.gitignore | 1 + .../Component/Component.csproj | 12 +++++ .../Component/Probe.razor | 5 ++ .../Consumer/Consumer.csproj | 14 +++++ .../TypeArgumentConstraints/Consumer/Seeds.cs | 50 +++++++++++++++++ .../Directory.Build.props | 3 ++ .../Library/Library.cs | 29 ++++++++++ .../Library/Library.csproj | 13 +++++ spikes/TypeArgumentConstraints/README.md | 54 +++++++++++-------- 9 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 spikes/TypeArgumentConstraints/.gitignore create mode 100644 spikes/TypeArgumentConstraints/Component/Component.csproj create mode 100644 spikes/TypeArgumentConstraints/Component/Probe.razor create mode 100644 spikes/TypeArgumentConstraints/Consumer/Consumer.csproj create mode 100644 spikes/TypeArgumentConstraints/Consumer/Seeds.cs create mode 100644 spikes/TypeArgumentConstraints/Directory.Build.props create mode 100644 spikes/TypeArgumentConstraints/Library/Library.cs create mode 100644 spikes/TypeArgumentConstraints/Library/Library.csproj diff --git a/spikes/TypeArgumentConstraints/.gitignore b/spikes/TypeArgumentConstraints/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/spikes/TypeArgumentConstraints/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/spikes/TypeArgumentConstraints/Component/Component.csproj b/spikes/TypeArgumentConstraints/Component/Component.csproj new file mode 100644 index 0000000..9eb251f --- /dev/null +++ b/spikes/TypeArgumentConstraints/Component/Component.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/spikes/TypeArgumentConstraints/Component/Probe.razor b/spikes/TypeArgumentConstraints/Component/Probe.razor new file mode 100644 index 0000000..90d9bde --- /dev/null +++ b/spikes/TypeArgumentConstraints/Component/Probe.razor @@ -0,0 +1,5 @@ +@using Library + +@code { + Box? _box; // CMTK0010 (claim 11: a Razor component, reported at the markup line) +} diff --git a/spikes/TypeArgumentConstraints/Consumer/Consumer.csproj b/spikes/TypeArgumentConstraints/Consumer/Consumer.csproj new file mode 100644 index 0000000..80893bc --- /dev/null +++ b/spikes/TypeArgumentConstraints/Consumer/Consumer.csproj @@ -0,0 +1,14 @@ + + + net10.0 + 14 + enable + enable + false + + + + + + + diff --git a/spikes/TypeArgumentConstraints/Consumer/Seeds.cs b/spikes/TypeArgumentConstraints/Consumer/Seeds.cs new file mode 100644 index 0000000..ebfcd00 --- /dev/null +++ b/spikes/TypeArgumentConstraints/Consumer/Seeds.cs @@ -0,0 +1,50 @@ +// Every line marked "// CMTK0010" must be reported, once, and no other line may be. The build output +// is the measurement: compare the reported line numbers with the marked ones (README). +using Library; +#pragma warning disable CS0169, CS0649, CS8618, CS0219 + +namespace Consumer; + +public sealed class OrderDecider : Decider { } // CMTK0010 (claim 1: base type, two arguments, two reports) + +public interface IHasBox : IComparer> { } // CMTK0010 (claim 2: implemented interface) + +public sealed class Members +{ + private Box _field; // CMTK0010 (claim 2: field) + public Box Property { get; set; } // CMTK0010 (claim 2: property) + public Box Method(Box parameter) // CMTK0010 (claim 2: return and parameter, two reports) + { + Box local = null!; // CMTK0010 (claim 2: local) + var open = typeof(Box); // CMTK0010 (claim 3: typeof) + Runner.Run(new PlaceOrder()); // CMTK0010 (claim 3: written type argument) + Runner.Run(new PlaceOrder()); // CMTK0010 (claim 4: inferred, at the call) + Runner.Run(new PlaceOrder()); // fine: Command is abstract + return local; + } +} + +public abstract class Leaky : Decider { } // CMTK0010 (claim 5: forwarding without the attribute; OrderPlaced is sealed, so two) +public abstract class Sound<[CodoMetis.TypeKit.Abstract, CodoMetis.TypeKit.Record] TCommand> : Decider { } +public sealed class Closed : Sound { } // CMTK0010 (claim 5: the forwarded requirement, at its own use) + +public static class Methods +{ + public static void Call(T value) where T : struct => Runner.Boxed(); // claim 6: a generic method's own parameter, satisfied by struct + public static void Proof() where T : struct => Runner.Boxed(); + public static Box Unproven() where T : class => new(); // CMTK0010 (claim 6: a method's parameter without proof) +} + +public static class Shapes +{ + public static Box SealedRecord; + public static Box Struct; + public static Box Enum; + public static Factory Concrete; + public static Factory ConcreteStruct; + public static Box Interface; // CMTK0010 (claim 8: an interface is not sealed) + public static Decider InterfaceNotAbstract; // CMTK0010 (claim 8: an interface is not abstract, and not a record: two) + public static Factory AbstractNotConcrete; // CMTK0010 (claim 8) + public static Decider AbstractClassNotRecord; // CMTK0010 (claim 9: [Abstract, Record], abstract but not a record) + public static Decider Fine; +} diff --git a/spikes/TypeArgumentConstraints/Directory.Build.props b/spikes/TypeArgumentConstraints/Directory.Build.props new file mode 100644 index 0000000..578e79b --- /dev/null +++ b/spikes/TypeArgumentConstraints/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/spikes/TypeArgumentConstraints/Library/Library.cs b/spikes/TypeArgumentConstraints/Library/Library.cs new file mode 100644 index 0000000..74ee48b --- /dev/null +++ b/spikes/TypeArgumentConstraints/Library/Library.cs @@ -0,0 +1,29 @@ +using CodoMetis.TypeKit; + +namespace Library; + +public abstract record Command; +public abstract record Event; +public sealed record PlaceOrder : Command; +public sealed record OrderPlaced : Event; +public abstract class Handler { } +public interface IHandler { } +public sealed class OrderHandler : Handler { } +public struct Point { } + +/// The library author's generic: the command and event families must be abstract records. +public abstract class Decider<[Abstract, Record] TCommand, [Abstract, Record] TEvent> +{ + public Decider? Next; // claim 10: the open generic in its own body, silent + public static Type Open => typeof(Decider<,>); // claim 10: unbound, silent +} + +public sealed class Box<[Sealed] T> { } + +public sealed class Factory<[Concrete] T> { } + +public static class Runner +{ + public static void Run<[Abstract] T>(T command) { } + public static Box Boxed() where T : struct => new(); // claim 5: struct proves [Sealed] +} diff --git a/spikes/TypeArgumentConstraints/Library/Library.csproj b/spikes/TypeArgumentConstraints/Library/Library.csproj new file mode 100644 index 0000000..973f2b9 --- /dev/null +++ b/spikes/TypeArgumentConstraints/Library/Library.csproj @@ -0,0 +1,13 @@ + + + net10.0 + 14 + enable + enable + + + + + + + diff --git a/spikes/TypeArgumentConstraints/README.md b/spikes/TypeArgumentConstraints/README.md index 2309720..08b04e9 100644 --- a/spikes/TypeArgumentConstraints/README.md +++ b/spikes/TypeArgumentConstraints/README.md @@ -1,6 +1,6 @@ # Spike: can an attribute on a type parameter constrain its arguments where C# cannot? -**Status: outline, not yet run.** Proposed 2026-10-13, from the "Constraints a type parameter +**Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "Constraints a type parameter cannot declare" part of phase 13 (2.0.0) in [docs/plan.md](../../docs/plan.md) §2, decision 43, and the CMTK0010 row of §10. @@ -15,15 +15,27 @@ An analyzer rather than an aspect, because an aspect on the parameter sees the d never the use, and the use site need not compile with Metalama (§3: the base package is Metalama-free, and discovery is by the attribute symbol). -**Answer: pending.** +**Answer: yes.** Measured 2026-10-13 on .NET SDK 10.0.401 with the rule as `.Analyzers` ships it +(`TypeArgumentConstraintAnalyzer`), over a library declaring the generics, a consumer of it and a +Razor class library. Every seeded line is reported, with the expected count where a line breaks +two constraints, and nothing else is: one report per written argument, one per inferred call, none +for the open generic in its own body or an unbound `typeof`. A forwarding type parameter is a breach +unless it carries the attribute, or `struct`/`new()` proves it. Shipped in 2.0.0 as CMTK0010 with +`TypeArgumentConstraintAnalyzerTests` holding each row. -## The candidate (planned) +## The candidate -The layout follows [spikes/FabricSpike](../FabricSpike/README.md): an analyzer project -(`Candidate`, netstandard2.0, the rule as `.Analyzers` would ship it), a library declaring the -attributes and a `Base<[Abstract] TCommand, [Record] TEvent>`, and a consumer project with one -seeded violation per claim, each on its own line, so `dotnet build` output is the measurement: the -set of reported lines must equal the set of seeded lines. +`Library/` declares `Decider<[Abstract, Record] TCommand, [Abstract, Record] TEvent>`, +`Box<[Sealed] T>`, `Factory<[Concrete] T>` and `Runner.Run<[Abstract] T>` against the real base +package; `Consumer/Seeds.cs` has one breach per claim, each on a line ending in `// CMTK0010`, so +the build output is the measurement; `Component/Probe.razor` is claim 11. The rule is the real one, +referenced from `src/`, not a stand-in. To repeat: + +```bash +dotnet build spikes/TypeArgumentConstraints/Consumer --no-incremental 2>&1 | grep -o -E "Seeds.cs\([0-9]+,[0-9]+\): error CMTK0010" | sort -u +grep -n "// CMTK0010" spikes/TypeArgumentConstraints/Consumer/Seeds.cs | cut -d: -f1 +dotnet build spikes/TypeArgumentConstraints/Component --no-incremental 2>&1 | grep -o "Probe.razor([0-9]*,[0-9]*): error CMTK0010" +``` ## Findings @@ -32,16 +44,16 @@ Each row is a claim the spike must settle. A seed that breaks the guard proves e | # | Claim | Result | |---|---|---| -| 1 | A base type `: Base` is reported at the type argument, naming the parameter, the argument and the constraint | | -| 2 | An implemented generic interface, a field, a property, a parameter, a return type and a local are each reported | | -| 3 | A `typeof(Base)` and a generic method call with explicit type arguments are reported | | -| 4 | A generic method call with inferred type arguments is reported, at the call, since no argument is written | | -| 5 | Forwarding: `class Sub : Base` is reported unless `U` carries `[Abstract]` itself; with it, `Sub` is reported at its own closed use | | -| 6 | A constraint on a type parameter of a generic method (`void M<[Sealed] T>()`) is checked at its calls | | -| 7 | One site is reported once, where a type appears in both syntax and a symbol action | | -| 8 | `[Record]` accepts a record class and a record struct; `[Abstract]` accepts an abstract class or record and refuses an interface; `[Sealed]` accepts a sealed class, a struct and a record struct; `[Concrete]` refuses an abstract type and an interface | | -| 9 | The four attributes compose: `[Abstract, Record]` requires both | | -| 10 | The open generic itself (`Base` inside its own body) is never reported | | -| 11 | A closed use in a Razor component is reported (§10, generated code with a mapped location) | | -| 12 | `MakeGenericType` at run time is out of reach, stated in the README, and the attribute carries no run-time check | | -| 13 | The attributes have `AttributeTargets.GenericParameter` only, so a misplaced one is CS0592 | | +| 1 | A base type `: Base` is reported at the type argument, naming the parameter, the argument and the constraint | measured: `Decider` reports at each argument, naming `TCommand`/`TEvent` of `Decider` and the breach | +| 2 | An implemented generic interface, a field, a property, a parameter, a return type and a local are each reported | measured: an implemented `IComparer>`, a field, a property, a return type, a parameter and a local each report once | +| 3 | A `typeof(Base)` and a generic method call with explicit type arguments are reported | measured: `typeof(Box)` and `Runner.Run(…)` report at the argument | +| 4 | A generic method call with inferred type arguments is reported, at the call, since no argument is written | measured: `Runner.Run(new PlaceOrder())` reports at the call, once; the written form is not reported twice | +| 5 | Forwarding: `class Sub : Base` is reported unless `U` carries `[Abstract]` itself; with it, `Sub` is reported at its own closed use | measured: `Leaky : Decider` reports `TCommand` for each attribute it lacks; `Sound<[Abstract, Record] TCommand>` is silent and `Closed : Sound` reports at its own use | +| 6 | A constraint on a type parameter of a generic method (`void M<[Sealed] T>()`) is checked at its calls | measured: `Boxed() where T : struct` is silent (struct proves sealed); `Unproven() where T : class` reports `T` | +| 7 | One site is reported once, where a type appears in both syntax and a symbol action | measured: the reported line counts equal the seeded ones, 2 where two arguments break, 3 for the forwarding line with two attributes and a sealed event | +| 8 | `[Record]` accepts a record class and a record struct; `[Abstract]` accepts an abstract class or record and refuses an interface; `[Sealed]` accepts a sealed class, a struct and a record struct; `[Concrete]` refuses an abstract type and an interface | measured: a sealed record, a struct and an enum pass `[Sealed]`, an interface fails it and `[Abstract]`; an abstract class fails `[Record]` and `[Concrete]`; a struct passes `[Concrete]` | +| 9 | The four attributes compose: `[Abstract, Record]` requires both | measured: `[Abstract, Record]` reports each attribute broken, so an abstract class reports `is not a record` alone | +| 10 | The open generic itself (`Base` inside its own body) is never reported | measured: `Decider` inside its own body and `typeof(Decider<,>)` report nothing; the library builds clean | +| 11 | A closed use in a Razor component is reported (§10, generated code with a mapped location) | measured: `Probe.razor(4,9): error CMTK0010` in a Razor class library | +| 12 | `MakeGenericType` at run time is out of reach, stated in the README, and the attribute carries no run-time check | by design: no run-time check, the README says `MakeGenericType` is out of reach | +| 13 | The attributes have `AttributeTargets.GenericParameter` only, so a misplaced one is CS0592 | measured in `TypeArgumentConstraintAnalyzerTests`: `[Abstract]` on a class is CS0592 | From 2963c92e7b3a546b40619b8f43485868c3cd35e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:12:26 +0200 Subject: [PATCH 010/147] feat: [Abstract], [Sealed], [Record], [Concrete] on type parameters, and CMTK0010 Four attributes in the base package say what C# has no where-clause for, and the rule reports a type argument that breaks them at any closed use: at the argument where a generic name is written, at the call where the type arguments are inferred. A forwarding type parameter must carry the attribute, or the requirement would end at it; struct proves [Sealed] and [Concrete], new() proves [Concrete]. Seeded: with forwarding always proven, the two tests that guard it failed. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 + README.md | 2 +- docs/plan.md | 11 +- .../AnalyzerReleases.Unshipped.md | 1 + .../DiagnosticIds.cs | 2 +- src/CodoMetis.TypeKit.Analyzers/README.md | 37 ++- .../TypeArgumentConstraintAnalyzer.cs | 153 ++++++++++++ .../TypeKitSymbols.cs | 35 ++- src/CodoMetis.TypeKit/README.md | 4 +- .../TypeArgumentConstraints.cs | 52 ++++ .../TypeArgumentConstraintAnalyzerTests.cs | 222 ++++++++++++++++++ .../TypeArgumentConstraintSamples.cs | 20 ++ 12 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs create mode 100644 src/CodoMetis.TypeKit/TypeArgumentConstraints.cs create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Analyzers/TypeArgumentConstraintSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6099562..01605fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ Semantic Versioning reserves for a major, listed under Changed as they land. task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through every overload, a delegate that returns a `Task` given to `Catch` is a compile error that names `CatchAsync`, and a delegate that returns null throws as `Result.Success(null)` does. +- **`CodoMetis.TypeKit.Analyzers`**: CMTK0010, the constraints a type parameter cannot declare. + `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` (new in the base package) on a type parameter + say what C# has no `where` for, and the rule reports a type argument that breaks it at any closed + use: a base type, a member, a parameter, a local, a `typeof`, a call with its arguments written + or inferred. A type parameter forwarded without the attribute is a breach too, so the requirement + follows the generic chain; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0011, with the package's first code fix. A type marked `[RequireParameterlessConstructor]` (new in the base package, with `ConstructorAccessibility`) requires every concrete type that derives from or implements it, directly or through another, to diff --git a/README.md b/README.md index d44025e..db31ddc 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ your own, and it can be declared inside another type. | Package | Role | Metalama | |---|---|---| | [CodoMetis.TypeKit](src/CodoMetis.TypeKit/README.md) | `Option`, `Result`, `Result`, the value-object contracts, the text guards `AsText()` and `AsIdentifier()`, and the analyzers that guard them | no | -| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0009 and CMTK0011: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values; no type without the constructor its host will look for. In Razor components too. Arrives with the base package | no | +| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0011: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values; no type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`; no type without the constructor its host will look for. In Razor components too. Arrives with the base package | no | | [CodoMetis.TypeKit.Generators](src/CodoMetis.TypeKit.Generators/README.md) | The compile-time generation, in the project that declares value objects | yes | | [CodoMetis.TypeKit.EntityFrameworkCore](src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) | Value objects as columns with nothing registered per type, and `.Value` in LINQ | no | | [CodoMetis.TypeKit.AspNetCore](src/CodoMetis.TypeKit.AspNetCore/README.md) | Value objects in the OpenAPI document, with the schema of the type they wrap; a `Result` as the response, a fault as problem details | no | diff --git a/docs/plan.md b/docs/plan.md index 750fe0e..11e025f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -567,8 +567,13 @@ Each phase ends green, and its guards have been proven by seeding the defect conventions. The fix spells the id and the property name it reads, held to the analyzer's by a test. Named `Require…`, beside `[RequireCustomInitialization]`, since the rule requires and the fix writes; nothing is introduced. - 5. **CMTK0010**, after [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md), - since the forwarding claim decides whether the rule is sound. + 5. **CMTK0010** ✅ 2026-10-13, after [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md), + run with the real rule over a library, a consumer and a Razor component: every seeded breach + reported once, the forwarding hole closed (a type parameter carries the attribute, or `struct` + and `new()` prove `[Sealed]`/`[Concrete]`), nothing for the open generic in its own body. The + attributes are `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` in the base package; a + generic name is checked where it is written, at the argument, and a call with inferred type + arguments at the call. 6. **`Patch`**, after its spike, in the base package and the ASP.NET satellite. It depends on nothing above and nothing below. 7. **The closed-set family**, after its spike: the generators, then each satellite. @@ -1593,7 +1598,7 @@ exist. | CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | -| CMTK0010 | A type argument that violates a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a `typeof`, a call with inferred type arguments. A type parameter forwarded without the same attribute is a violation too. Reflection is out of reach | Error | proposed for 2.0.0 (phase 13), a major, so Error is within decision 34 | +| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within decision 34 | | CMTK0011 | A concrete type implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a type declaring no constructor satisfies `Public`. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md index 7478341..d724e13 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md @@ -5,4 +5,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------------------------------------------------------------- +CMTK0010 | Usage | Error | TypeArgumentConstraintAnalyzer, a type argument that breaks [Abstract], [Sealed], [Record] or [Concrete] on its parameter CMTK0011 | Usage | Error | RequiredParameterlessConstructorAnalyzer, a concrete type under [RequireParameterlessConstructor] without the constructor it requires diff --git a/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs b/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs index 360b4cb..4633318 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs @@ -24,7 +24,7 @@ internal static class DiagnosticIds public const string DefaultProducingCall = "CMTK0009"; - // CMTK0010 is reserved for the type-argument constraint rule (docs/plan.md §10). + public const string TypeArgumentConstraint = "CMTK0010"; public const string RequiredParameterlessConstructor = "CMTK0011"; diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 69d78a3..6601a36 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -21,6 +21,7 @@ appears to: an instance that passed no factory, or a value object that was never | CMTK0007 | Info | `FromKnownGood` given a value that comes straight from a parameter. | | CMTK0008 | Warning | The wrapped values of two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves through `Equals(object)`. | | CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind. | +| CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call, written or inferred. A type parameter forwarded without the attribute is a breach too. | | CMTK0011 | Error | A concrete type that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. The code fix writes or changes the constructor. | The ids are public contract and never change meaning. The generators' own build errors, @@ -308,6 +309,37 @@ asynchronous forms, it is the type the task returns. An overload given a default element type whose default is null or an ordinary value, are not reported. EF Core's forms are recognised in any project that references EF Core. A warning, since code that checks the sequence is not empty first is correct and the rule cannot tell. +## CMTK0010 + +C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` +or `record`. A generic whose argument must be an abstract record, the command family of an +aggregate, gets no help from the compiler, and the closed type that breaks it compiles. Four +attributes on a type parameter say what C# cannot, and this rule reports the use that breaks it, +at the argument where one is written and at the call where the arguments are inferred: + +```csharp +public abstract class Decider<[Abstract] TCommand, [Record] TEvent> { } + +public sealed record PlaceOrder : Command; + +public sealed class OrderDecider : Decider { } // CMTK0010: PlaceOrder is not abstract + +public abstract class Leaky : Decider { } // CMTK0010: TCommand does not carry [Abstract] +public abstract class Sound<[Abstract] TCommand> : Decider { } +``` + +| Attribute | Accepts | A forwarding type parameter satisfies it when | +|---|---|---| +| `[Abstract]` | an abstract class or record; not an interface | it carries `[Abstract]` | +| `[Sealed]` | a sealed class or record, a struct, an enum | it carries `[Sealed]` or is constrained to `struct` | +| `[Record]` | a record class or record struct | it carries `[Record]` | +| `[Concrete]` | a class, record or struct that is not abstract, static or an interface; no constructor is demanded, unlike `new()` | it carries `[Concrete]` or is constrained to `struct` or `new()` | + +A type parameter given as the argument must carry the attribute itself, or the requirement would end +at the first forwarding parameter and `Sound` would never be reported. The open generic +inside its own body and an unbound `typeof(Decider<,>)` are never reported. Reflection, +`MakeGenericType`, is out of reach, and the attributes carry no run-time check. + ## CMTK0011 A type the host creates by reflection, an endpoint, a module, an entity configuration, needs a @@ -346,8 +378,9 @@ for `.razor` and `.cshtml` files, not `[*.cs]`, not `[*.razor]`, not even `[*]`. there too, put the line in a `.globalconfig` file, which applies to every file of the project. The errors are the rules where the code is wrong whatever surrounds it: a `default` written out -(CMTK0001), a value object nothing generates (CMTK0002), validation bypassed (CMTK0004), and a -constructor a host will look for and not find (CMTK0011). A +(CMTK0001), a value object nothing generates (CMTK0002), validation bypassed (CMTK0004), a type +argument that breaks what its parameter declares (CMTK0010), and a constructor a host will look for +and not find (CMTK0011). A warning is what gets ignored, so these fail the build. The others are warnings, or for CMTK0007 Info, because correct code can look the same: an array filled in a loop straight after it is created (CMTK0005), a member a framework sets (CMTK0006), a sequence known not to be empty (CMTK0009), a diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs new file mode 100644 index 0000000..bccdc09 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// CMTK0010: a type argument that breaks a constraint its parameter declares by attribute, +/// [Abstract], [Sealed], [Record] or [Concrete], at any closed use. +/// +/// +/// +/// The constraint is declared where C# cannot express it (where T : abstract), and the +/// violation is at the use site, which may be in any project, so this is an analyzer and not an +/// aspect. Every generic name written in source is checked where it is written, at the argument; +/// a call whose type arguments are inferred has no such name and is checked at the call. +/// +/// +/// A type parameter given as the argument satisfies a constraint only when it carries the same +/// attribute, or a C# constraint that proves it (struct for [Sealed] and +/// [Concrete], new() for [Concrete]). Otherwise the requirement would end at +/// the first forwarding parameter, and Sub<ConcreteCommand> would compile. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TypeArgumentConstraintAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticIds.TypeArgumentConstraint, + title: "Type argument breaks a constraint its parameter declares", + messageFormat: "'{0}', the type argument for '{1}' of '{2}', {3}", + category: "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A type parameter marked [Abstract], [Sealed], [Record] or [Concrete] constrains its arguments where C# cannot. A closed use with an argument that breaks the constraint compiles; this rule is its error.", + helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.TypeArgumentConstraint) + ); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCode.AnalysisFlags); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(start => + { + if (TypeKitSymbols.Resolve(start.Compilation) is not { HasTypeArgumentConstraints: true } symbols) return; + + start.RegisterSyntaxNodeAction(node => AnalyzeGenericName(node, symbols), SyntaxKind.GenericName); + start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols), OperationKind.Invocation); + }); + } + + /// A generic name written in source, a type's or a method's: each argument against its parameter, reported at the argument. + private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols) + { + var name = (GenericNameSyntax)context.Node; + if (name.IsUnboundGenericName) return; + + var info = context.SemanticModel.GetSymbolInfo(name, context.CancellationToken); + var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); + + var (parameters, arguments) = symbol switch + { + INamedTypeSymbol type when type.IsGenericType => (type.OriginalDefinition.TypeParameters, type.TypeArguments), + IMethodSymbol method when method.IsGenericMethod => (method.OriginalDefinition.TypeParameters, method.TypeArguments), + _ => (ImmutableArray.Empty, ImmutableArray.Empty), + }; + + if (parameters.Length != arguments.Length || parameters.Length != name.TypeArgumentList.Arguments.Count) return; + + for (var i = 0; i < parameters.Length; i++) + { + foreach (var breach in Breaches(symbols, parameters[i], arguments[i])) + context.Report(Diagnostic.Create(Rule, name.TypeArgumentList.Arguments[i].GetLocation(), arguments[i].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), parameters[i].Name, Owner(symbol!), breach)); + } + } + + /// A call whose type arguments are inferred: nothing is written, so it is reported at the call. + private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols) + { + var invocation = (IInvocationOperation)context.Operation; + var method = invocation.TargetMethod; + if (!method.IsGenericMethod) return; + + // Written type arguments are a generic name, checked where they are written. + var expression = invocation.Syntax is InvocationExpressionSyntax call ? call.Expression : null; + if (expression is GenericNameSyntax || expression is MemberAccessExpressionSyntax { Name: GenericNameSyntax }) return; + + var parameters = method.OriginalDefinition.TypeParameters; + var arguments = method.TypeArguments; + if (parameters.Length != arguments.Length) return; + + for (var i = 0; i < parameters.Length; i++) + { + foreach (var breach in Breaches(symbols, parameters[i], arguments[i])) + context.Report(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), arguments[i].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), parameters[i].Name, Owner(method), breach)); + } + } + + /// The generic as its declaration names it: Decider<TCommand, TEvent>, Runner.Run<T>. + private static string Owner(ISymbol symbol) => symbol switch + { + INamedTypeSymbol type => type.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), + IMethodSymbol method => $"{method.ContainingType.Name}.{method.Name}<{string.Join(", ", method.OriginalDefinition.TypeParameters.Select(parameter => parameter.Name))}>", + _ => symbol.Name, + }; + + /// What breaks of what declares, as the message's last clause. + private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol argument) + { + foreach (var constraint in symbols.ConstraintsOn(parameter)) + { + if (argument is ITypeParameterSymbol forwarded) + { + if (!Proves(symbols, forwarded, constraint)) + yield return $"is a type parameter that does not carry [{constraint}]"; + } + else if (!Satisfies(argument, constraint)) + { + yield return constraint switch + { + TypeArgumentConstraint.Abstract => "is not an abstract class or record", + TypeArgumentConstraint.Sealed => "is not sealed", + TypeArgumentConstraint.Record => "is not a record", + _ => "is not a concrete class, record or struct", + }; + } + } + } + + private static bool Satisfies(ITypeSymbol argument, TypeArgumentConstraint constraint) => constraint switch + { + TypeArgumentConstraint.Abstract => argument is INamedTypeSymbol { TypeKind: TypeKind.Class, IsAbstract: true, IsStatic: false }, + TypeArgumentConstraint.Sealed => argument.TypeKind is TypeKind.Struct or TypeKind.Enum || argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Delegate, IsSealed: true, IsStatic: false }, + TypeArgumentConstraint.Record => argument is INamedTypeSymbol { IsRecord: true }, + _ => argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct, IsAbstract: false, IsStatic: false }, + }; + + /// A forwarding type parameter proves a constraint by carrying the attribute, or by a C# constraint that implies it. + private static bool Proves(TypeKitSymbols symbols, ITypeParameterSymbol forwarded, TypeArgumentConstraint constraint) => + symbols.ConstraintsOn(forwarded).Contains(constraint) + || (constraint is TypeArgumentConstraint.Sealed or TypeArgumentConstraint.Concrete && forwarded.HasValueTypeConstraint) + || (constraint is TypeArgumentConstraint.Concrete && forwarded.HasConstructorConstraint); +} diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs index 2e1fc7d..a5f06e2 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs @@ -24,6 +24,15 @@ namespace CodoMetis.TypeKit.Analyzers; /// go silent and fail. /// /// +/// What a type parameter can demand of its argument by attribute; the names are the attributes'. +internal enum TypeArgumentConstraint +{ + Abstract, + Sealed, + Record, + Concrete, +} + internal sealed class TypeKitSymbols { /// @@ -39,6 +48,7 @@ internal sealed class TypeKitSymbols private readonly ImmutableArray _validatedMarkers; private readonly ImmutableArray _requireCustomInitialization; private readonly ImmutableArray _requireParameterlessConstructor; + private readonly ImmutableArray<(TypeArgumentConstraint Kind, INamedTypeSymbol Attribute)> _constraintAttributes; private readonly ImmutableArray _materializers; private readonly ImmutableArray _converters; private readonly ImmutableArray _outcomes; @@ -59,6 +69,7 @@ private TypeKitSymbols( ImmutableArray validatedMarkers, ImmutableArray requireCustomInitialization, ImmutableArray requireParameterlessConstructor, + ImmutableArray<(TypeArgumentConstraint Kind, INamedTypeSymbol Attribute)> constraintAttributes, ImmutableArray materializers, ImmutableArray converters, ImmutableArray outcomes, @@ -72,6 +83,7 @@ bool referencesGenerators _validatedMarkers = validatedMarkers; _requireCustomInitialization = requireCustomInitialization; _requireParameterlessConstructor = requireParameterlessConstructor; + _constraintAttributes = constraintAttributes; _materializers = materializers; _converters = converters; _resultClasses = resultClasses; @@ -98,6 +110,10 @@ bool referencesGenerators var validatedMarkers = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.ValueObjects.IValidatedValue`3"); var attributes = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.RequireCustomInitializationAttribute"); var constructors = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.RequireParameterlessConstructorAttribute"); + var constraints = ImmutableArray.CreateRange( + from kind in new[] { TypeArgumentConstraint.Abstract, TypeArgumentConstraint.Sealed, TypeArgumentConstraint.Record, TypeArgumentConstraint.Concrete } + from attribute in compilation.GetTypesByMetadataName($"CodoMetis.TypeKit.{kind}Attribute") + select (kind, attribute)); var materializers = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.ValueObjects.IValueObjectMaterializer`2"); var converters = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.EntityFrameworkCore.ValueObjectConverter`2"); var outcomes = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.Result`1") @@ -106,12 +122,12 @@ bool referencesGenerators var resultClasses = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.Result"); var optionClasses = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.Option"); - if (valueMarkers.IsEmpty && validatedMarkers.IsEmpty && attributes.IsEmpty && constructors.IsEmpty && materializers.IsEmpty) return null; + if (valueMarkers.IsEmpty && validatedMarkers.IsEmpty && attributes.IsEmpty && constructors.IsEmpty && constraints.IsEmpty && materializers.IsEmpty) return null; var referencesGenerators = compilation.ReferencedAssemblyNames.Any(identity => string.Equals(identity.Name, GeneratorsAssembly, StringComparison.OrdinalIgnoreCase)); - return new TypeKitSymbols(valueMarkers, validatedMarkers, attributes, constructors, materializers, converters, outcomes, resultClasses, optionClasses, referencesGenerators); + return new TypeKitSymbols(valueMarkers, validatedMarkers, attributes, constructors, constraints, materializers, converters, outcomes, resultClasses, optionClasses, referencesGenerators); } /// @@ -365,6 +381,21 @@ private static IEnumerable InterfacesOf(ITypeSymbol type) .FirstOrDefault(attribute => attribute.AttributeClass is { } attributeClass && _requireCustomInitialization.Contains(attributeClass, SymbolEqualityComparer.Default)); + /// Whether the compilation can see any of [Abstract], [Sealed], [Record] and [Concrete]. + public bool HasTypeArgumentConstraints => !_constraintAttributes.IsEmpty; + + /// The constraints a type parameter declares by attribute, in attribute order. + public IEnumerable ConstraintsOn(ITypeParameterSymbol parameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + foreach (var (kind, attributeClass) in _constraintAttributes) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) yield return kind; + } + } + } + /// Whether the compilation can see [RequireParameterlessConstructor] at all. public bool HasRequireParameterlessConstructor => !_requireParameterlessConstructor.IsEmpty; diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index c9d4358..a86d349 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -355,6 +355,8 @@ They arrive with this package, so whoever can see the interfaces gets the guard: - **CMTK0009** (warning): a call that hands out a `default` instance when it finds nothing, `ids.FirstOrDefault()` or `ProductCode.TryFrom(s).OrDefault()`. `FirstOrNone()`, `GetValueOrNone()` and `Or(fallback)` say what happens then. +- **CMTK0010**: a type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, + `[Record]` or `[Concrete]`, the constraints C# has no `where` for, at any closed use. - **CMTK0011**: a type under an interface or base class marked `[RequireParameterlessConstructor]` without the parameterless constructor it asks for, which the host would look for by reflection. The code fix writes it. @@ -481,7 +483,7 @@ stored. ## Where things are -`Option`, `Result`, `OrderId.New()`, `AsText()`, `AsIdentifier()`, `[RequireCustomInitialization]` and `[RequireParameterlessConstructor]` live in `CodoMetis.TypeKit`; +`Option`, `Result`, `OrderId.New()`, `AsText()`, `AsIdentifier()`, `[RequireCustomInitialization]`, `[RequireParameterlessConstructor]` and the type-parameter attributes `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` live in `CodoMetis.TypeKit`; every value-object contract in `CodoMetis.TypeKit.ValueObjects`. What only the generated code and the satellites call is in `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense. Targets .NET 10. Source and issues: [github.com/CaffeinatedCoder/CodoMetis.TypeKit](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit). diff --git a/src/CodoMetis.TypeKit/TypeArgumentConstraints.cs b/src/CodoMetis.TypeKit/TypeArgumentConstraints.cs new file mode 100644 index 0000000..3caf5f2 --- /dev/null +++ b/src/CodoMetis.TypeKit/TypeArgumentConstraints.cs @@ -0,0 +1,52 @@ +namespace CodoMetis.TypeKit; + +/// +/// Requires the type argument for this type parameter to be an abstract class or record. C# has no +/// where T : abstract; the CodoMetis.TypeKit analyzer reports a closed use that breaks it +/// (CMTK0010). +/// +/// +/// A type parameter given as the argument satisfies it only when it carries [Abstract] +/// itself, so the requirement follows the generic chain instead of ending at the first forwarding +/// parameter. An interface is not abstract in this sense. Reflection (MakeGenericType) is +/// out of the analyzer's reach. +/// +[AttributeUsage(AttributeTargets.GenericParameter)] +public sealed class AbstractAttribute : Attribute; + +/// +/// Requires the type argument for this type parameter to be sealed: a sealed class or record, a +/// struct or an enum. C# has no where T : sealed; the CodoMetis.TypeKit analyzer reports a +/// closed use that breaks it (CMTK0010). +/// +/// +/// A type parameter given as the argument satisfies it when it carries [Sealed] itself or is +/// constrained to struct. Reflection is out of the analyzer's reach. +/// +[AttributeUsage(AttributeTargets.GenericParameter)] +public sealed class SealedAttribute : Attribute; + +/// +/// Requires the type argument for this type parameter to be a record, class or struct. C# has no +/// where T : record; the CodoMetis.TypeKit analyzer reports a closed use that breaks it +/// (CMTK0010). +/// +/// +/// A type parameter given as the argument satisfies it only when it carries [Record] itself. +/// Reflection is out of the analyzer's reach. +/// +[AttributeUsage(AttributeTargets.GenericParameter)] +public sealed class RecordAttribute : Attribute; + +/// +/// Requires the type argument for this type parameter to be concrete: a class, record or struct that +/// is not abstract, not static and not an interface. new() says that too, and demands a public +/// parameterless constructor with it; this does not. The CodoMetis.TypeKit analyzer reports a closed +/// use that breaks it (CMTK0010). +/// +/// +/// A type parameter given as the argument satisfies it when it carries [Concrete] itself or +/// is constrained to struct or new(). Reflection is out of the analyzer's reach. +/// +[AttributeUsage(AttributeTargets.GenericParameter)] +public sealed class ConcreteAttribute : Attribute; diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs new file mode 100644 index 0000000..a138155 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -0,0 +1,222 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0010: a type argument that breaks what its parameter declares by attribute. Each claim of +/// spikes/TypeArgumentConstraints is a test here. +/// +public sealed class TypeArgumentConstraintAnalyzerTests +{ + /// The generic types and the candidates, present in every test. + private const string Subjects = + """ + using System; + using CodoMetis.TypeKit; + using Subjects; + + namespace Subjects + { + public abstract record Command; + public sealed record PlaceOrder : Command; + public abstract class Handler { } + public interface IHandler { } + public sealed class OrderHandler : Handler { } + public struct Point { } + public static class Helpers { } + + public abstract class Decider<[Abstract] TCommand, [Record] TEvent> { } + + public sealed class Box<[Sealed] T> { } + + public sealed class Factory<[Concrete] T> { } + + public static class Runner + { + public static void Run<[Abstract] T>(T command) { } + public static void Seal<[Sealed] T>(T value) { } + } + } + + """; + + private static AnalyzerTest Test(string declarations, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(Subjects + declarations); + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static DiagnosticResult Cmtk0010(int location, string argument, string parameter, string owner, string breach) => + new DiagnosticResult("CMTK0010", DiagnosticSeverity.Error).WithLocation(location).WithArguments(argument, parameter, owner, breach); + + [Fact] + public void The_descriptor_is_the_published_contract() + { + var rule = new TypeArgumentConstraintAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + rule.Id.ShouldBe("CMTK0010"); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); + rule.IsEnabledByDefault.ShouldBeTrue(); + } + + [Fact] + public Task A_base_type_is_reported_at_the_argument_that_breaks_it() => + Test( + "public sealed class OrderDecider : Decider<{|#0:PlaceOrder|}, PlaceOrder> { }", + new DiagnosticResult("CMTK0010", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'PlaceOrder', the type argument for 'TCommand' of 'Decider', is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Members_parameters_returns_locals_and_typeof_are_each_reported() => + Test( + """ + public sealed class Uses + { + private Box<{|#0:Handler|}> _field; + public Box<{|#1:Handler|}> Property { get; set; } + public Box<{|#2:Handler|}> Method(Box<{|#3:Handler|}> parameter) + { + Box<{|#4:Handler|}> local = null!; + var type = typeof(Box<{|#5:Handler|}>); + return local; + } + } + """, + Cmtk0010(0, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(1, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(2, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(3, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(4, "Handler", "T", "Box", "is not sealed"), + Cmtk0010(5, "Handler", "T", "Box", "is not sealed")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_call_with_written_type_arguments_is_reported_at_the_argument_once() => + Test( + """ + public static class Calls + { + public static void Go() => Runner.Run<{|#0:PlaceOrder|}>(new PlaceOrder()); + } + """, + Cmtk0010(0, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_call_with_inferred_type_arguments_is_reported_at_the_call() => + Test( + """ + public static class Calls + { + public static void Go() => {|#0:Runner.Run(new PlaceOrder())|}; + } + """, + Cmtk0010(0, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + /// A forwarding parameter carries the attribute, or the requirement ends at it. + [Fact] + public Task A_forwarding_type_parameter_must_carry_the_attribute() => + Test( + """ + public abstract class Leaky : Decider<{|#0:TCommand|}, PlaceOrder> { } + + public abstract class Sound<[Abstract] TCommand> : Decider { } + + public sealed class Closed : Sound<{|#1:PlaceOrder|}> { } + """, + Cmtk0010(0, "TCommand", "TCommand", "Decider", "is a type parameter that does not carry [Abstract]"), + Cmtk0010(1, "PlaceOrder", "TCommand", "Sound", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_struct_or_new_constraint_proves_sealed_and_concrete() => + Test( + """ + public static class Proofs + { + public static Box Boxed() where T : struct => new(); + public static Factory Made() where T : new() => new(); + public static Factory<{|#0:T|}> Unproven() where T : class => new(); + } + """, + Cmtk0010(0, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Each_constraint_accepts_and_refuses_what_it_says() => + Test( + """ + public sealed class Accepted : Decider { } + public static class Also + { + public static Box SealedRecord; + public static Box Struct; + public static Box Enum; + public static Factory Concrete; + public static Factory ConcreteStruct; + public static Box<{|#0:IHandler|}> Interface; + public static Decider<{|#1:IHandler|}, PlaceOrder> InterfaceIsNotAbstract; + public static Decider NotARecord; + public static Factory<{|#3:Handler|}> Abstract; + public static Factory<{|#4:IHandler|}> InterfaceNotConcrete; + } + """, + Cmtk0010(0, "IHandler", "T", "Box", "is not sealed"), + Cmtk0010(1, "IHandler", "TCommand", "Decider", "is not an abstract class or record"), + Cmtk0010(2, "OrderHandler", "TEvent", "Decider", "is not a record"), + Cmtk0010(3, "Handler", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(4, "IHandler", "T", "Factory", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task Two_attributes_on_one_parameter_each_report() => + Test( + """ + public sealed class Both<[Abstract, Record] T> { } + public static class Uses + { + public static Both<{|#0:OrderHandler|}> Neither; + public static Both<{|#1:PlaceOrder|}> RecordButSealed; + public static Both Fine; + } + """, + Cmtk0010(0, "OrderHandler", "T", "Both", "is not an abstract class or record"), + new DiagnosticResult("CMTK0010", DiagnosticSeverity.Error).WithLocation(0).WithArguments("OrderHandler", "T", "Both", "is not a record"), + Cmtk0010(1, "PlaceOrder", "T", "Both", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + /// The open generic inside its own body, and an unbound typeof, are never reported. + [Fact] + public Task The_open_generic_and_an_unbound_typeof_stay_silent() => + Test( + """ + public abstract class Self<[Abstract] T> + { + public Self? Next; + public static Type Open => typeof(Self<>); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_misplaced_attribute_is_the_compiler_s_error() => + new AnalyzerTest( + """ + using CodoMetis.TypeKit; + + [{|CS0592:Abstract|}] + public sealed class NotAParameter { } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// Robustness, not a guard: without CodoMetis.TypeKit there is nothing to look at, and nothing may fail. + [Fact] + public Task A_compilation_without_TypeKit_is_left_alone() => + new AnalyzerTest("public sealed class Box { } public static class U { public static Box B; }", referenceTypeKit: false) + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/TypeArgumentConstraintSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/TypeArgumentConstraintSamples.cs new file mode 100644 index 0000000..3cb8ce8 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/TypeArgumentConstraintSamples.cs @@ -0,0 +1,20 @@ +using CodoMetis.TypeKit; + +namespace ReadmeSamples.Analyzers.TypeArgumentConstraints; + +public abstract record Command; + +public sealed record OrderPlaced; + +#pragma warning disable CMTK0010 // what the sample shows the rule reporting +// sample: CodoMetis.TypeKit.Analyzers/cmtk0010 +public abstract class Decider<[Abstract] TCommand, [Record] TEvent> { } + +public sealed record PlaceOrder : Command; + +public sealed class OrderDecider : Decider { } // CMTK0010: PlaceOrder is not abstract + +public abstract class Leaky : Decider { } // CMTK0010: TCommand does not carry [Abstract] +public abstract class Sound<[Abstract] TCommand> : Decider { } +// end sample +#pragma warning restore CMTK0010 From b8f4b4f9d0df54eeda59b490a9ab53e3463db709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:36:35 +0200 Subject: [PATCH 011/147] spike: MergePatch, run and answered: a merge-patch field under source generation and Native AOT MakeGenericType + Activator has no constructor to invoke under AOT, a static field read by reflection is trimmed, an open generic converter in [JsonConverter] is SYSLIB1220; a registry the closed type's static constructor fills, run by the factory, passes every claim. A primitive inside a patch takes the serializer's built-in converter; the document inlines a patch as its argument, never required, admitting null. Co-Authored-By: Claude Fable 5.1 --- spikes/MergePatch/.gitignore | 1 + spikes/MergePatch/Candidate.cs | 216 ++++++++++++++++++++++++ spikes/MergePatch/Directory.Build.props | 3 + spikes/MergePatch/MergePatch.csproj | 37 ++++ spikes/MergePatch/Model.cs | 53 ++++++ spikes/MergePatch/Program.cs | 175 +++++++++++++++++++ spikes/MergePatch/README.md | 47 ++++++ 7 files changed, 532 insertions(+) create mode 100644 spikes/MergePatch/.gitignore create mode 100644 spikes/MergePatch/Candidate.cs create mode 100644 spikes/MergePatch/Directory.Build.props create mode 100644 spikes/MergePatch/MergePatch.csproj create mode 100644 spikes/MergePatch/Model.cs create mode 100644 spikes/MergePatch/Program.cs create mode 100644 spikes/MergePatch/README.md diff --git a/spikes/MergePatch/.gitignore b/spikes/MergePatch/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/spikes/MergePatch/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/spikes/MergePatch/Candidate.cs b/spikes/MergePatch/Candidate.cs new file mode 100644 index 0000000..ea2cece --- /dev/null +++ b/spikes/MergePatch/Candidate.cs @@ -0,0 +1,216 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace MergePatch; + +public enum PatchState { Absent, Null, Value } + +/// Shape A: a converter factory that closes a generic converter with MakeGenericType, and a static reference from the struct to that converter so the instantiation exists under Native AOT. +#if FACTORY_STATIC +[JsonConverter(typeof(PatchAStaticFieldFactory))] +#elif FACTORY_REGISTRY +[JsonConverter(typeof(PatchARegistryFactory))] +#else +[JsonConverter(typeof(PatchAJsonConverterFactory))] +#endif +public readonly record struct PatchA +{ + // The instantiation PatchAJsonConverter is compiled whenever PatchA is (claim 3). Under + // Native AOT that is not enough for MakeGenericType + Activator: the constructor has no + // reflection invoker ("missing native code or metadata", measured). The static field, read by + // reflection, and the registry the static constructor fills are the two reflection-light ways + // from a Type to the instance. + internal static readonly PatchAJsonConverter Converter = new(); + + static PatchA() => PatchARegistryFactory.Register(typeof(PatchA), Converter); + + private readonly T? _value; + private readonly PatchState _state; + + private PatchA(T? value, PatchState state) { _value = value; _state = state; } + + public static PatchA Absent() => default; + public static PatchA Null() => new(default, PatchState.Null); + public static PatchA Of(T value) => new(value, PatchState.Value); + + public PatchState State => _state; + + public TResult Match(Func onValue, Func onNull, Func onAbsent) => + _state switch { PatchState.Value => onValue(_value!), PatchState.Null => onNull(), _ => onAbsent() }; + + public bool TryGetValue([NotNullWhen(true)] out T? value) { value = _value; return _state == PatchState.Value; } + + public override string ToString() => $"PatchA {{ State = {_state} }}"; +} + +public sealed class PatchAJsonConverter : JsonConverter> +{ + public override bool HandleNull => true; + + public override PatchA Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return PatchA.Null(); + + var value = JsonSerializer.Deserialize(ref reader, ContractOf(options)); + return value is null ? PatchA.Null() : PatchA.Of(value); + } + + public override void Write(Utf8JsonWriter writer, PatchA value, JsonSerializerOptions options) + { + switch (value.State) + { + case PatchState.Absent: + throw new NotSupportedException("An absent patch cannot be written. Mark the property [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)], so an absent patch is left out of the document."); + case PatchState.Null: + writer.WriteNullValue(); + break; + default: + value.TryGetValue(out var inner); + JsonSerializer.Serialize(writer, inner!, ContractOf(options)); + break; + } + } + + /// + /// T's contract: the options' own where they have one; else made as the generated converters make a + /// wrapped type's (GeneratedJson.TypeInfo): a registered converter, the type's own attribute (a value + /// object), or the serializer's built-in converter for a primitive. A class T that a source-generated + /// context never saw, because the patch's converter hides it, is refused with the serializer's own + /// message naming the context (claim 4). + /// + private static JsonTypeInfo ContractOf(JsonSerializerOptions options) => + options.TryGetTypeInfo(typeof(T), out var info) ? (JsonTypeInfo)info : GeneratedJson.TypeInfo(options, BuiltIn()); + + private static JsonConverter? BuiltIn() => (JsonConverter?)(object?)( + typeof(T) == typeof(string) ? JsonMetadataServices.StringConverter + : typeof(T) == typeof(int) ? JsonMetadataServices.Int32Converter + : typeof(T) == typeof(long) ? JsonMetadataServices.Int64Converter + : typeof(T) == typeof(bool) ? JsonMetadataServices.BooleanConverter + : typeof(T) == typeof(decimal) ? JsonMetadataServices.DecimalConverter + : typeof(T) == typeof(double) ? JsonMetadataServices.DoubleConverter + : typeof(T) == typeof(Guid) ? JsonMetadataServices.GuidConverter + : typeof(T) == typeof(DateTime) ? JsonMetadataServices.DateTimeConverter + : typeof(T) == typeof(DateTimeOffset) ? JsonMetadataServices.DateTimeOffsetConverter + : typeof(T) == typeof(DateOnly) ? JsonMetadataServices.DateOnlyConverter + : typeof(T) == typeof(TimeOnly) ? JsonMetadataServices.TimeOnlyConverter + : (JsonConverter?)null); +} + +public sealed class PatchAJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(PatchA<>); + + // The instantiation exists: PatchA holds a static PatchAJsonConverter, compiled with it. Measured in this spike under Native AOT. + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "PatchA.Converter keeps PatchAJsonConverter compiled for every T a consumer uses; measured in spikes/MergePatch under Native AOT.")] + [UnconditionalSuppressMessage("Trimming", "IL2055", Justification = "Same: the closed converter type is reached from PatchA, never only here.")] + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var argument = typeToConvert.GetGenericArguments()[0]; + var closed = typeof(PatchAJsonConverter<>).MakeGenericType(argument); + return (JsonConverter)Activator.CreateInstance(closed)!; + } +} + +/// Shape A, variant: no MakeGenericType at all. The factory reads the static converter PatchA<T> holds, through the field the struct keeps for every T it is used with. +public sealed class PatchAStaticFieldFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(PatchA<>); + + [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Measured: the field is kept with PatchA under Native AOT, or it is not, which this spike shows.")] + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + (JsonConverter)typeToConvert.GetField("Converter", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; +} + +/// Shape A, variant: a registry the struct's static constructor fills; the factory runs that constructor for the closed type and looks the converter up. No reflection over members at all. +public sealed class PatchARegistryFactory : JsonConverterFactory +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary Registry = new(); + + public static void Register(Type patch, JsonConverter converter) => Registry[patch] = converter; + + public override bool CanConvert(Type typeToConvert) => + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(PatchA<>); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeToConvert.TypeHandle); + return Registry.TryGetValue(typeToConvert, out var converter) + ? converter + : throw new NotSupportedException($"{typeToConvert} registered no converter: its static constructor did not run."); + } +} + +#if OPEN_GENERIC +/// Shape B: an open generic converter type named by the attribute, closed by the serializer if it supports that. +[JsonConverter(typeof(PatchBJsonConverter<>))] +public readonly record struct PatchB +{ + private readonly T? _value; + private readonly PatchState _state; + private PatchB(T? value, PatchState state) { _value = value; _state = state; } + public static PatchB Null() => new(default, PatchState.Null); + public static PatchB Of(T value) => new(value, PatchState.Value); + public PatchState State => _state; +} + +public sealed class PatchBJsonConverter : JsonConverter> +{ + public override bool HandleNull => true; + public override PatchB Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null ? PatchB.Null() : PatchB.Of(JsonSerializer.Deserialize(ref reader, (JsonTypeInfo)options.GetTypeInfo(typeof(T)))!); + public override void Write(Utf8JsonWriter writer, PatchB value, JsonSerializerOptions options) => throw new NotSupportedException(); +} +#endif + +/// +/// The candidate schema transformer. A patch property is described as its argument's schema, inlined +/// (the reference-id hook below returns null for a patch), never required, and admitting null, since +/// the Null state is part of the wire format whatever the argument: whether the application accepts +/// a clear is its own rule, not the document's. +/// +public sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer +{ + public static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(PatchA<>); + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var info = context.JsonTypeInfo; + + // The object holding patch properties: none of them is required. + if (info.Kind == JsonTypeInfoKind.Object && schema.Required is { Count: > 0 } required) + { + foreach (var property in info.Properties) + if (IsPatch(property.PropertyType)) required.Remove(property.Name); + } + + if (!IsPatch(info.Type)) return; + + var argument = info.Type.GetGenericArguments()[0]; + + // A value object that appears only inside a patch has no metadata in a source-generated + // context (the patch's converter hides it), so ASP.NET cannot make its schema there (measured + // under Native AOT). Its wrapped type's schema is what the value-object transformer would have + // put in anyway: GeneratedValueObjectAttribute names it, as the satellites read it. + var wrapped = argument.GetCustomAttributes(inherit: false) + .Select(attribute => attribute.GetType()) + .FirstOrDefault(type => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CodoMetis.TypeKit.CompilerServices.GeneratedValueObjectAttribute<,>)) + ?.GetGenericArguments()[1]; + var inner = await context.GetOrCreateSchemaAsync(wrapped ?? argument, parameterDescription: null, cancellationToken); + + schema.Properties?.Clear(); + schema.Type = (inner.Type ?? JsonSchemaType.Null) | JsonSchemaType.Null; + schema.Format = inner.Format; + schema.Pattern = inner.Pattern; + schema.Items = inner.Items; + schema.AdditionalProperties = inner.AdditionalProperties; + schema.Description = $"A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it.{(inner.Description is null ? "" : " " + inner.Description)}"; + } +} diff --git a/spikes/MergePatch/Directory.Build.props b/spikes/MergePatch/Directory.Build.props new file mode 100644 index 0000000..578e79b --- /dev/null +++ b/spikes/MergePatch/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/spikes/MergePatch/MergePatch.csproj b/spikes/MergePatch/MergePatch.csproj new file mode 100644 index 0000000..83d4cfa --- /dev/null +++ b/spikes/MergePatch/MergePatch.csproj @@ -0,0 +1,37 @@ + + + Exe + net10.0 + 14 + enable + enable + + $(NoWarn);ASP0020 + + true + + + true + $(DefineConstants);AOT + + + + $(DefineConstants);FACTORY_STATIC + + + $(DefineConstants);FACTORY_REGISTRY + + + + $(DefineConstants);OPEN_GENERIC + + + + + + + + + + + diff --git a/spikes/MergePatch/Model.cs b/spikes/MergePatch/Model.cs new file mode 100644 index 0000000..58563af --- /dev/null +++ b/spikes/MergePatch/Model.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace MergePatch; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); +} + +public sealed record Address(string Street, string City); + +/// Shape A's request: a reference type, a value object, a primitive, a class, a nullable value object. +public sealed record UpdateA( + PatchA Name, + PatchA Email, + PatchA Quantity, + PatchA Address); + +/// What the write claims serialize: the ignore condition that leaves an absent patch out. +public sealed record OutgoingA( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] PatchA Name, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] PatchA Quantity); + +public sealed record BareOutgoingA(PatchA Name); + +public sealed record PatchStates(string Name, string Email, string Quantity, string Address); + +#if OPEN_GENERIC +public sealed record UpdateB(PatchB Name, PatchB Quantity); +#endif + +// The analyzers run here as in a consumer: a default patch is a state and must stay silent (claim 9). +public sealed class Defaults +{ + public PatchA Unassigned { get; set; } + public static PatchA Written() => default; + public static PatchA Made() => new(); +} + +[JsonSerializable(typeof(UpdateA))] +[JsonSerializable(typeof(Address))] // a class a patch wraps must be in the context: the patch's converter hides it from the generator (claim 4) +[JsonSerializable(typeof(OutgoingA))] +[JsonSerializable(typeof(BareOutgoingA))] +[JsonSerializable(typeof(PatchStates))] +#if OPEN_GENERIC +[JsonSerializable(typeof(UpdateB))] +#endif +internal partial class SpikeJson : JsonSerializerContext; diff --git a/spikes/MergePatch/Program.cs b/spikes/MergePatch/Program.cs new file mode 100644 index 0000000..02ac6ef --- /dev/null +++ b/spikes/MergePatch/Program.cs @@ -0,0 +1,175 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit.AspNetCore; +using MergePatch; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.OpenApi; +using System.Text.Json.Serialization.Metadata; + +Directory.CreateDirectory("out"); +var flavour = System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported ? "jit" : "aot"; +var failures = 0; + +void Claim(int number, string text, bool holds) +{ + Console.WriteLine($"{(holds ? "PASS" : "FAIL")} {number,2} {text}"); + if (!holds) failures++; +} + +static string State(PatchA patch) => patch.Match(v => $"Value({v})", () => "Null", () => "Absent"); + +// ---- claims 1, 2, 4, 5: reading, under the reflection resolver and the source-generated context +var resolvers = new List<(string Name, JsonSerializerOptions Options)> { ("source-generated", new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = SpikeJson.Default }) }; +#if !AOT +resolvers.Insert(0, ("reflection", new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() })); +#endif +foreach (var (name, options) in resolvers) +{ + + UpdateA Read(string json) => JsonSerializer.Deserialize(json, (JsonTypeInfo)options.GetTypeInfo(typeof(UpdateA)))!; + string Write(T value) => JsonSerializer.Serialize(value, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); + + var absent = Read("""{}"""); + Claim(1, $"[{name}] every property absent reads as Absent (default): {State(absent.Name)}, {State(absent.Email)}, {State(absent.Quantity)}, {State(absent.Address)}", + absent.Name.State == PatchState.Absent && absent.Email.State == PatchState.Absent && absent.Quantity.State == PatchState.Absent && absent.Address.State == PatchState.Absent); + + var nulls = Read("""{"name":null,"email":null,"quantity":null,"address":null}"""); + Claim(2, $"[{name}] null reads as Null for every T, the value object included: {State(nulls.Name)}, {State(nulls.Email)}, {State(nulls.Quantity)}, {State(nulls.Address)}", + nulls.Name.State == PatchState.Null && nulls.Email.State == PatchState.Null && nulls.Quantity.State == PatchState.Null && nulls.Address.State == PatchState.Null); + + try + { + var values = Read("""{"name":"Ada","email":"ada@example.com","quantity":3,"address":{"street":"1 Main","city":"Oslo"}}"""); + Claim(2, $"[{name}] a value reads as Value, a primitive and a class under the context's own metadata, the value object under its converter: {State(values.Name)}, {State(values.Email)}, {State(values.Quantity)}, {State(values.Address)}", + values.Name.TryGetValue(out var n) && n == "Ada" && values.Email.TryGetValue(out var e) && e.Value == "ada@example.com" + && values.Quantity.TryGetValue(out var q) && q == 3 && values.Address.TryGetValue(out var a) && a.City == "Oslo"); + } + catch (Exception e) + { + Claim(4, $"[{name}] T's contract: {e.GetType().Name}: {e.Message[..Math.Min(160, e.Message.Length)]}", false); + } + + try + { + Read("""{"email":"not-an-address"}"""); + Claim(5, $"[{name}] a refused value object inside a patch is refused", false); + } + catch (JsonException e) + { + Claim(5, $"[{name}] a refused value object inside a patch throws the value object's own JsonException, naming no input: {e.Message[..Math.Min(120, e.Message.Length)]}", e.Message.Contains("Email") && !e.Message.Contains("not-an-address")); + } + + // ---- claim 7: writing + var outgoing = Write(new OutgoingA(PatchA.Null(), PatchA.Absent())); + Claim(7, $"[{name}] Absent is left out with WhenWritingDefault, Null writes null: {outgoing}", outgoing == """{"name":null}"""); + var written = Write(new OutgoingA(PatchA.Of("Ada"), PatchA.Of(2))); + Claim(7, $"[{name}] Value writes T: {written}", written == """{"name":"Ada","quantity":2}"""); + try + { + Write(new BareOutgoingA(PatchA.Absent())); + Claim(7, $"[{name}] writing an Absent patch without the ignore condition is refused", false); + } + catch (NotSupportedException e) + { + Claim(7, $"[{name}] writing an Absent patch without the ignore condition is refused, naming the condition: {e.Message[..60]}…", e.Message.Contains("WhenWritingDefault")); + } +} + +// ---- claim 3 variant: the factory without MakeGenericType, reading the struct's static field +try +{ + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = SpikeJson.Default }; + options.Converters.Insert(0, new PatchAStaticFieldFactory()); + var read = JsonSerializer.Deserialize("""{"quantity":5,"email":"a@b.c"}""", (JsonTypeInfo)options.GetTypeInfo(typeof(UpdateA)))!; + Claim(3, $"static-field factory (no MakeGenericType): {State(read.Quantity)}, {State(read.Email)}", read.Quantity.TryGetValue(out var q) && q == 5 && read.Email.State == PatchState.Value); +} +catch (Exception e) +{ + Claim(3, $"static-field factory (no MakeGenericType): {e.GetType().Name}: {e.Message[..Math.Min(140, e.Message.Length)]}", false); +} + +#if OPEN_GENERIC +try +{ + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = SpikeJson.Default }; + var b = JsonSerializer.Deserialize("""{"name":"Ada","quantity":null}""", (JsonTypeInfo)options.GetTypeInfo(typeof(UpdateB)))!; + Claim(4, $"shape B, an open generic converter in [JsonConverter]: {b.Name.State}, {b.Quantity.State}", b.Name.State == PatchState.Value && b.Quantity.State == PatchState.Null); +} +catch (Exception e) +{ + Claim(4, $"shape B, an open generic converter in [JsonConverter]: {e.GetType().Name}: {e.Message[..Math.Min(160, e.Message.Length)]}", false); +} +#endif + +// ---- claims 5 (HTTP), 8: a host +foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0 }) +foreach (var transform in new[] { false, true }) +{ + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { EnvironmentName = Environments.Development }); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.ConfigureHttpJsonOptions(json => json.SerializerOptions.TypeInfoResolverChain.Insert(0, SpikeJson.Default)); + builder.Services.AddProblemDetails(); + builder.Services.AddOpenApi(openApi => + { + openApi.OpenApiVersion = version; + openApi.AddTypeKit(); + if (transform) + { + var inner = openApi.CreateSchemaReferenceId; + openApi.CreateSchemaReferenceId = type => PatchSchemaTransformer.IsPatch(type.Type) ? null : inner(type); + openApi.AddSchemaTransformer(); + } + }); + + await using var app = builder.Build(); + app.UseStatusCodePages(); + app.MapOpenApi(); + app.MapPatch("/orders/{id}", (int id, UpdateA update) => Results.Ok(new PatchStates(State(update.Name), State(update.Email), State(update.Quantity), State(update.Address)))); + await app.StartAsync(); + + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.First(); + using var client = new HttpClient { BaseAddress = new Uri(address) }; + + if (version == OpenApiSpecVersion.OpenApi3_1 && !transform) + { + var ok = await client.PatchAsync("/orders/1", new StringContent("""{"name":"Ada","quantity":null}""", Encoding.UTF8, "application/json")); + var body = await ok.Content.ReadAsStringAsync(); + Claim(5, $"HTTP: a patch body binds through the context: {(int)ok.StatusCode} {body}", ok.StatusCode == HttpStatusCode.OK && body.Contains("Value(Ada)") && body.Contains("\"quantity\":\"Null\"") && body.Contains("\"email\":\"Absent\"")); + + var refused = await client.PatchAsync("/orders/1", new StringContent("""{"email":"nope"}""", Encoding.UTF8, "application/json")); + var problem = await refused.Content.ReadAsStringAsync(); + Claim(5, $"HTTP: a refused value object inside a patch is 400, the input not quoted: {(int)refused.StatusCode} {problem[..Math.Min(100, problem.Length)]}", refused.StatusCode == HttpStatusCode.BadRequest && !problem.Contains("nope")); + } + + var response = await client.GetAsync("/openapi/v1.json"); + var document = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) { Claim(8, $"document {version} transform={transform}: {(int)response.StatusCode} {document[..Math.Min(400, document.Length)]}", false); await app.StopAsync(); continue; } + var file = $"out/{flavour}-{(version == OpenApiSpecVersion.OpenApi3_1 ? "3.1" : "3.0")}-{(transform ? "transformed" : "plain")}.json"; + await File.WriteAllTextAsync(file, document); + + var schema = JsonNode.Parse(document)!["components"]?["schemas"]?["UpdateA"]; + var props = schema?["properties"]; + var required = schema?["required"]?.AsArray().Select(n => n!.GetValue()).ToArray() ?? []; + var nameSchema = props?["name"]?.ToJsonString(); + var emailSchema = props?["email"]?.ToJsonString(); + var quantitySchema = props?["quantity"]?.ToJsonString(); + Console.WriteLine($" [{Path.GetFileName(file)}] required=[{string.Join(",", required)}] name={nameSchema} email={emailSchema} quantity={quantitySchema}"); + if (transform) + { + var nullable = version == OpenApiSpecVersion.OpenApi3_1 ? "\"null\"" : "\"nullable\":true"; + Claim(8, $"[{Path.GetFileName(file)}] a patch property is its argument's schema inlined, not required, admitting null", + required.Length == 0 + && nameSchema is not null && !nameSchema.Contains("$ref") && nameSchema.Contains("string") && nameSchema.Contains(nullable) + && quantitySchema is not null && !quantitySchema.Contains("$ref") && quantitySchema.Contains("integer") && quantitySchema.Contains(nullable) + && emailSchema is not null && !emailSchema.Contains("$ref") && emailSchema.Contains("string") && emailSchema.Contains(nullable)); + } + await app.StopAsync(); +} + +Console.WriteLine(failures == 0 ? "ALL PASS" : $"{failures} FAILED"); +return failures; diff --git a/spikes/MergePatch/README.md b/spikes/MergePatch/README.md new file mode 100644 index 0000000..7ee3055 --- /dev/null +++ b/spikes/MergePatch/README.md @@ -0,0 +1,47 @@ +# Spike: can a merge-patch field tell absent from null, read a value object, and survive Native AOT? + +**Status: run 2026-10-13, answered.** From the "`Patch`" part of phase 13 (2.0.0) in +[docs/plan.md](../../docs/plan.md) §2. + +**Question.** A JSON Merge Patch (RFC 7396) field has three states, absent, null and a value, and +System.Text.Json calls no converter for a property it does not find. Can one struct in the base +package carry the three, with `default` as absent, read a value object with its own rule, write +through `[JsonIgnore(WhenWritingDefault)]`, be described in the OpenAPI document as its argument, +and do all of it under a source-generated context and Native AOT, where a converter over an open +`T` is the hard part? + +**Answer: yes, with the converter registered by the struct's own static constructor.** Measured +2026-10-13 on .NET SDK 10.0.401, Microsoft.AspNetCore.OpenApi 10.0.12, against a value object woven +by the real generators and the real OpenAPI satellite, under the reflection resolver, a +source-generated context, and published with Native AOT. Of the converter shapes, `MakeGenericType` +plus `Activator` fails under AOT ("missing native code or metadata": the closed converter type +exists, its constructor has no reflection invoker), a static field on the struct read by reflection +is trimmed, and an open generic converter type in `[JsonConverter]` is refused by the source +generator (SYSLIB1220, no metadata). A registry the closed `Patch`'s static constructor fills, +which the factory runs with `RuntimeHelpers.RunClassConstructor` for the type it is asked for, passes +every claim under AOT, with one IL2059 trim warning whose reason is this measurement. Shipped in 2.0.0 +as `Patch` with `PatchTests`, `PatchSchemaTests` and the consumer smoke test holding each row. + +## The candidate + +A web project: `Candidate.cs` holds `PatchA` with the three factories selectable by +`-p:Factory=Static|Registry` (the default is `MakeGenericType`), shape B behind `-p:OpenGeneric=true`, +and the schema transformer; `Model.cs` a woven `Email`, the request records and the context; +`Program.cs` runs each claim under both resolvers, then a host for the HTTP and document claims, +writing the documents to `out/`. `dotnet run -p:Factory=Registry` is the JIT run and +`dotnet publish -c Release -p:Aot=true -p:Factory=Registry -o out/aot-Registry` the AOT one. + +## Findings + +| # | Claim | Result | +|---|---|---| +| 1 | `default` is absent: a property not in the document reads as Absent, under both resolvers | measured: all four properties Absent, reflection and source-generated, JIT and AOT | +| 2 | null reads as Null and a value as Value, for a string, a value object, an int and a class | measured, both resolvers; the value object through its generated converter | +| 3 | A converter over an open `T` under Native AOT | measured: `MakeGenericType` + `Activator` throws "missing native code or metadata"; the static field read by reflection is null (trimmed); the registry filled by the static constructor works for every `T`, including the record-struct value object | +| 4 | T's contract under a source-generated context | measured: the generator never sees `T` behind the converter, so `string` and `int` had no metadata until the converter supplied `JsonMetadataServices`'s built-in ones; a class must be listed in the context, and the serializer's own message says so; the open generic `[JsonConverter(typeof(C<>))]` is SYSLIB1220 | +| 5 | A value `Create` refuses inside a patch is refused as anywhere else, and a bound body answers 400 | measured: `Email refused the JSON value (NoAt)`, the input not quoted; HTTP 400 problem details | +| 6 | A null for a value object is the Null state, not a refusal | measured; so `Patch` is never needed, and a `Nullable<>` argument has no contract in a source-generated context anyway | +| 7 | Writing: Absent is left out under `WhenWritingDefault`, refused without it; Null writes null; Value writes T | measured, both resolvers, JIT and AOT | +| 8 | The document: a patch property is its argument's schema, inlined, not required, admitting null, in 3.1 and 3.0 | measured, JIT and AOT, once the reference-id hook inlines the patch, the parent's `required` drops patch properties, and a value object is described through its wrapped type (its own contract is absent under source generation) | +| 9 | CMTK0001, CMTK0005 and CMTK0006 are silent on a default, a `new()`, an array and an unassigned member of `Patch` | measured: the spike's build reports no CMTK diagnostic | +| 10 | Native AOT publish of the registry shape: no trim or AOT warning in the candidate beyond IL2059 at `RunClassConstructor` | measured; the binary passes every claim but the static-field variant, which is recorded above as unviable | From c3c7a0c02aff13e2e6bc91f62c7d3ca597b28905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:36:35 +0200 Subject: [PATCH 012/147] feat: Patch, a JSON Merge Patch field: absent, null or a value default is absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never refuses a null; a value object inside a patch keeps its own rule. Match, TryGetValue, IfValue, IfPresent and ToOption apply it without reflection; writing an absent patch is refused and WhenWritingDefault leaves it out. The converter is registered by the closed type's static constructor, which is what works under Native AOT; a primitive takes the serializer's built-in converter. AddTypeKit() describes a patch property as its argument's schema, inlined, not required, admitting null. Seeded: without the registration seven tests fail, without null handling two. The consumer smoke test exercises a patch under Native AOT. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 12 + docs/plan.md | 15 +- .../PatchSchemaTransformer.cs | 61 +++++ src/CodoMetis.TypeKit.AspNetCore/README.md | 6 + .../TypeKitOpenApiOptionsExtensions.cs | 2 + .../CompilerServices/BuiltInConverters.cs | 44 ++++ src/CodoMetis.TypeKit/Patch.cs | 245 ++++++++++++++++++ src/CodoMetis.TypeKit/README.md | 50 +++- .../PatchIsDefaultableTests.cs | 34 +++ .../PatchSchemaTests.cs | 84 ++++++ .../Base/PatchSamples.cs | 65 +++++ .../NotWireTypeTests.cs | 4 +- .../NullDelegateTests.cs | 9 + test/CodoMetis.TypeKit.Tests/PatchTests.cs | 177 +++++++++++++ test/consumer-smoke-test.sh | 12 + 15 files changed, 815 insertions(+), 5 deletions(-) create mode 100644 src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs create mode 100644 src/CodoMetis.TypeKit/CompilerServices/BuiltInConverters.cs create mode 100644 src/CodoMetis.TypeKit/Patch.cs create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/PatchIsDefaultableTests.cs create mode 100644 test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs create mode 100644 test/CodoMetis.TypeKit.Tests/PatchTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 01605fb..99eae28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,18 @@ Semantic Versioning reserves for a major, listed under Changed as they land. task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through every overload, a delegate that returns a `Task` given to `Catch` is a compile error that names `CatchAsync`, and a delegate that returns null throws as `Result.Success(null)` does. +- **`CodoMetis.TypeKit`**: `Patch`, a field of a JSON Merge Patch (RFC 7396): absent, null or a + value, where System.Text.Json alone cannot tell an absent property from a null one. `default` is + absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never + refuses a null; a value object inside a patch keeps its own rule. `Match`, `TryGetValue`, `IfValue`, + `IfPresent` and `ToOption` apply it without reflection. Writing an absent patch is refused, and + `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out. Under a source-generated context a + primitive, a string, a `Guid`, a date, a `Uri` and a value object need no listing; a class or an + enum does. Works under Native AOT: the converter is registered by the closed type's static + constructor, since a converter built by reflection has no code there. +- **`CodoMetis.TypeKit.AspNetCore`**: `AddTypeKit()` describes a `Patch` property as `T`'s schema, + inlined, not required and admitting null, in 3.1 and 3.0; a value object inside a patch through the + type it wraps. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0010, the constraints a type parameter cannot declare. `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` (new in the base package) on a type parameter say what C# has no `where` for, and the rule reports a type argument that breaks it at any closed diff --git a/docs/plan.md b/docs/plan.md index 11e025f..0113ca1 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -574,8 +574,17 @@ Each phase ends green, and its guards have been proven by seeding the defect attributes are `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` in the base package; a generic name is checked where it is written, at the argument, and a call with inferred type arguments at the call. - 6. **`Patch`**, after its spike, in the base package and the ASP.NET satellite. It depends - on nothing above and nothing below. + 6. **`Patch`** ✅ 2026-10-13, after [spikes/MergePatch](../spikes/MergePatch/README.md), in the + base package and the ASP.NET satellite. The spike settled the AOT shape: a converter type built + with `MakeGenericType` has no constructor to invoke under Native AOT, a static field read by + reflection is trimmed, an open generic converter in `[JsonConverter]` is refused by the source + generator (SYSLIB1220); what works is a registry the closed `Patch`'s static constructor + fills, which the factory runs for the type asked for (`RunClassConstructor`, one IL2059 + suppression with that measurement as its reason). A primitive inside a patch takes the + serializer's built-in converter, since the source generator never sees what a patch wraps; a + class is listed in the context. The document inlines a patch as its argument's schema, never + required, always admitting null; a value object inside a patch is described through what it + wraps, since a source-generated host has no contract for it. 7. **The closed-set family**, after its spike: the generators, then each satellite. 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. @@ -653,7 +662,7 @@ Each phase ends green, and its guards have been proven by seeding the defect `PopulateMetadata` handed an empty service provider. - **`CustomizeProblemDetails` sees the feature** under JIT and Native AOT, and the feature is absent for every problem `ToHttpResult()` did not write. - - **`Patch`, a field of a JSON Merge Patch (RFC 7396)**, proposed 2026-10-11 for 1.x. It has + - **`Patch`, a field of a JSON Merge Patch (RFC 7396)**, proposed 2026-10-11 for 1.x; built 2026-10-13 (step 6 above answers the questions). It has three states: absent, null, or a value. System.Text.Json cannot tell an absent property from a null, so every PATCH endpoint rebuilds this type by hand. It is a wire type on purpose, beside `Option`, which still refuses JSON (decision 15): `Option` says whether a value exists, `Patch` diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs new file mode 100644 index 0000000..20d55cd --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// A property in the document: its argument's schema, inlined, never +/// required, and admitting null, since the Null state is part of the wire format whatever the +/// argument is. +/// +/// +/// +/// Inlined rather than a PatchOfstring component: makes ASP.NET's +/// reference-id hook answer null for a patch, so the schema is written where the property is, and +/// this transformer then fills it from the argument's. +/// +/// +/// A value object that appears only inside a patch has no metadata in a source-generated context, +/// since the patch's converter hides it, and ASP.NET cannot make its schema there (measured under +/// Native AOT in spikes/MergePatch). Its wrapped type's schema is asked for instead, which is what +/// the value-object transformer would have put in. +/// +/// +internal sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer +{ + /// A patch is inlined where it is used, never hoisted into a component of its own. + public static void Apply(OpenApiOptions options) + { + var inner = options.CreateSchemaReferenceId; + options.CreateSchemaReferenceId = type => IsPatch(type.Type) ? null : inner(type); + } + + private static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Patch<>); + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var info = context.JsonTypeInfo; + + // The object holding patch properties: none of them is required, since absent is a state. + if (info.Kind == JsonTypeInfoKind.Object && schema.Required is { Count: > 0 } required) + { + foreach (var property in info.Properties) + { + if (IsPatch(property.PropertyType)) required.Remove(property.Name); + } + } + + if (!IsPatch(info.Type)) return; + + var argument = info.Type.GetGenericArguments()[0]; + var described = ValueObjectTypes.WrappedType(argument) ?? argument; + var inner = await context.GetOrCreateSchemaAsync(described, parameterDescription: null, cancellationToken); + + schema.Properties?.Clear(); + schema.FillFrom(inner); + schema.Type = (schema.Type ?? JsonSchemaType.Null) | JsonSchemaType.Null; + schema.Description = "A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it." + + (inner.Description is null ? "" : " " + inner.Description); + } +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 7fcefdb..061b15b 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -108,6 +108,12 @@ no component at all), and both parameters are a bare `string`. - **What is already said is kept.** Whatever a value object's schema already says, from ASP.NET or another transformer, is not overwritten; only what it leaves open is filled in. +**A merge patch.** A property typed `Patch` (CodoMetis.TypeKit, a JSON Merge Patch field) is +described as `T`'s schema, inlined where the property is rather than as a `PatchOfstring` component, +never required, since absent is a state, and admitting null, since null is the clear. A value object +inside a patch is described through the type it wraps, which is also what a source-generated host +can do: the patch's converter hides the value object from its context. + ## A Result as a response `ToHttpResult()` turns a `Result` into what an endpoint returns: a success as `200 OK` with its value diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs index 9e0ac16..65f5a7e 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs @@ -43,8 +43,10 @@ public static OpenApiOptions AddTypeKit(this OpenApiOptions options) ArgumentNullException.ThrowIfNull(options); ValueObjectSchemaReferenceIds.Apply(options); + PatchSchemaTransformer.Apply(options); return options.AddSchemaTransformer() + .AddSchemaTransformer() .AddOperationTransformer(); } } diff --git a/src/CodoMetis.TypeKit/CompilerServices/BuiltInConverters.cs b/src/CodoMetis.TypeKit/CompilerServices/BuiltInConverters.cs new file mode 100644 index 0000000..02f0984 --- /dev/null +++ b/src/CodoMetis.TypeKit/CompilerServices/BuiltInConverters.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace CodoMetis.TypeKit.CompilerServices; + +/// +/// The serializer's own converter for a primitive, a string, a , a date, a +/// or a , for a type a source-generated context never saw. +/// The generated value-object converters pass the one for their wrapped type at compile time; a +/// generic type over any argument picks it here. +/// +internal static class BuiltInConverters +{ + /// The built-in converter for , or null where the serializer has none. + /// The type. + public static JsonConverter? For() => (JsonConverter?)(object?)( + typeof(T) == typeof(string) ? JsonMetadataServices.StringConverter + : typeof(T) == typeof(int) ? JsonMetadataServices.Int32Converter + : typeof(T) == typeof(long) ? JsonMetadataServices.Int64Converter + : typeof(T) == typeof(bool) ? JsonMetadataServices.BooleanConverter + : typeof(T) == typeof(decimal) ? JsonMetadataServices.DecimalConverter + : typeof(T) == typeof(double) ? JsonMetadataServices.DoubleConverter + : typeof(T) == typeof(float) ? JsonMetadataServices.SingleConverter + : typeof(T) == typeof(Guid) ? JsonMetadataServices.GuidConverter + : typeof(T) == typeof(DateTime) ? JsonMetadataServices.DateTimeConverter + : typeof(T) == typeof(DateTimeOffset) ? JsonMetadataServices.DateTimeOffsetConverter + : typeof(T) == typeof(DateOnly) ? JsonMetadataServices.DateOnlyConverter + : typeof(T) == typeof(TimeOnly) ? JsonMetadataServices.TimeOnlyConverter + : typeof(T) == typeof(TimeSpan) ? JsonMetadataServices.TimeSpanConverter + : typeof(T) == typeof(short) ? JsonMetadataServices.Int16Converter + : typeof(T) == typeof(ushort) ? JsonMetadataServices.UInt16Converter + : typeof(T) == typeof(uint) ? JsonMetadataServices.UInt32Converter + : typeof(T) == typeof(ulong) ? JsonMetadataServices.UInt64Converter + : typeof(T) == typeof(byte) ? JsonMetadataServices.ByteConverter + : typeof(T) == typeof(sbyte) ? JsonMetadataServices.SByteConverter + : typeof(T) == typeof(char) ? JsonMetadataServices.CharConverter + : typeof(T) == typeof(Half) ? JsonMetadataServices.HalfConverter + : typeof(T) == typeof(Int128) ? JsonMetadataServices.Int128Converter + : typeof(T) == typeof(UInt128) ? JsonMetadataServices.UInt128Converter + : typeof(T) == typeof(Uri) ? JsonMetadataServices.UriConverter + : typeof(T) == typeof(Version) ? JsonMetadataServices.VersionConverter + : typeof(T) == typeof(byte[]) ? JsonMetadataServices.ByteArrayConverter + : (JsonConverter?)null); +} diff --git a/src/CodoMetis.TypeKit/Patch.cs b/src/CodoMetis.TypeKit/Patch.cs new file mode 100644 index 0000000..7a7440d --- /dev/null +++ b/src/CodoMetis.TypeKit/Patch.cs @@ -0,0 +1,245 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using CodoMetis.TypeKit.CompilerServices; + +namespace CodoMetis.TypeKit; + +/// What a JSON Merge Patch field says about a value. +public enum PatchState +{ + /// The field was not mentioned: the value stays as it is. The default of a . + Absent, + + /// The field was null: the value is cleared. + Null, + + /// The field carried a value: the value is set to it. + Value +} + +/// +/// A field of a JSON Merge Patch (RFC 7396): absent, null, or a value. System.Text.Json cannot +/// tell an absent property from a null one, and every PATCH endpoint rebuilt this by hand. +/// +/// +/// +/// A wire type, on purpose, beside , which refuses JSON: an option says +/// whether a value exists, a patch whether a request mentioned it. The serializer calls no converter +/// for a property it does not find, so default(Patch<T>) is , +/// the one default of this package that means something, and the analyzers leave it alone. +/// +/// +/// Reading never refuses a null: the Null state is part of the wire format whatever +/// is, and whether a clear is accepted is the application's rule, said in . +/// A Patch<T?> over a struct is therefore never needed. A value 's +/// own converter refuses, a value object's rule, is refused as it is anywhere else. +/// +/// +/// Writing an absent patch is refused: a converter cannot leave a property out. Mark a property that +/// is written [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)], and an absent +/// patch is left out of the document. +/// +/// +/// Under a source-generated , the context never sees +/// , since this type's converter hides it. A primitive, a string, a +/// , a date or a is read with the serializer's built-in converter, +/// a value object with its own; any other type, a class or an enum, is listed in the context, or the +/// serializer refuses with its own message naming the context. +/// +/// +/// Applied without reflection: patch.Name.IfValue(name => order.Name = name) for a field a +/// clear cannot reach, patch.Nickname.IfPresent(nickname => order.Nickname = nickname) for one +/// it can. ToString() never prints the value. +/// +/// +/// The type of the value. +[JsonConverter(typeof(PatchJsonConverterFactory))] +[DebuggerDisplay("{State}")] +public readonly record struct Patch +{ + private readonly T? _value; + private readonly PatchState _state; + + // The converter for this closed type, registered where the type itself is compiled: under Native + // AOT, a converter type built with MakeGenericType has no constructor to invoke, and a static field + // read by reflection is trimmed (measured, spikes/MergePatch). The factory runs this constructor + // for the type it is asked for and looks the converter up. + static Patch() => PatchJsonConverterFactory.Register(typeof(Patch), new PatchJsonConverter()); + + internal Patch(T? value, PatchState state) + { + _value = value; + _state = state; + } + + /// Absent, null or a value. + public PatchState State => _state; + + /// Picks the branch for the state. All three are required: a patch is handled whole, or not at all. + /// Called with the value when the field carried one. + /// Called when the field was null: the value is to be cleared. + /// Called when the field was not mentioned: the value stays. + /// The type of the outcome. + /// The branch's outcome. + /// A delegate is null. + public TResult Match(Func onValue, Func onNull, Func onAbsent) + { + ArgumentNullException.ThrowIfNull(onValue); + ArgumentNullException.ThrowIfNull(onNull); + ArgumentNullException.ThrowIfNull(onAbsent); + + return _state switch + { + PatchState.Value => onValue(_value!), + PatchState.Null => onNull(), + _ => onAbsent(), + }; + } + + /// The value, when the field carried one. + /// The value, or default. + /// Whether the field carried a value. + public bool TryGetValue([MaybeNullWhen(false)] out T value) + { + value = _value!; + return _state == PatchState.Value; + } + + /// Runs with the value when the field carried one, for a target a clear cannot reach. + /// What to do with the value. + /// is null. + public void IfValue(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (_state == PatchState.Value) action(_value!); + } + + /// Runs with the value, or with null when the field was null, for a target a clear can reach. An absent field runs nothing. + /// What to do with the value or the clear. + /// is null. + public void IfPresent(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (_state != PatchState.Absent) action(_state == PatchState.Value ? _value : default); + } + + /// The state only, never the value. + public override string ToString() => $"Patch {{ State = {_state} }}"; +} + +/// The three states of a , and ToOption(). +public static class Patch +{ + /// A field that was not mentioned: the default. + /// The type of the value. + /// An absent patch. + public static Patch Absent() => default; + + /// A field that was null: a clear. + /// The type of the value. + /// A null patch. + public static Patch Null() => new(default, PatchState.Null); + + /// A field that carried ; a null value is the Null state. + /// The value. + /// The type of the value. + /// A patch holding the value, or a null patch. + public static Patch Value(T? value) => value is null ? Null() : new(value, PatchState.Value); + + /// The patch. + /// The type of the value. + extension(Patch patch) where T : notnull + { + /// Some for a value, None for a null or an absent field: whether a value was given, when a clear and an absence are the same to the caller. + /// The value as an option. + public Option ToOption() => patch.TryGetValue(out var value) ? Option.Some(value) : Option.None(); + } +} + +/// +/// The JSON converter behind : a JSON null is the Null state, a value is read +/// with 's contract, and an absent property never reaches it. +/// +internal sealed class PatchJsonConverter : JsonConverter> +{ + /// A JSON null is a state, not the serializer's default. + public override bool HandleNull => true; + + public override Patch Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return Patch.Null(); + + return Patch.Value(JsonSerializer.Deserialize(ref reader, Contract(options))); + } + + public override void Write(Utf8JsonWriter writer, Patch value, JsonSerializerOptions options) + { + switch (value.State) + { + case PatchState.Absent: + throw new NotSupportedException( + $"An absent {Name()} cannot be written, since a converter cannot leave a property out. " + + "Mark the property [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)], and an absent patch is left out of the document." + Refusals.NotAWireType); + case PatchState.Null: + writer.WriteNullValue(); + break; + default: + value.TryGetValue(out var inner); + JsonSerializer.Serialize(writer, inner!, Contract(options)); + break; + } + } + + private static string Name() => $"Patch<{typeof(T).Name}>"; + + /// + /// 's contract: the options' own where they have one; else made as the + /// generated converters make a wrapped type's, from a registered converter, the type's own + /// attribute (a value object) or the serializer's built-in converter for a primitive. A class or an + /// enum a source-generated context never saw is refused by the serializer, naming the context. + /// + private static JsonTypeInfo Contract(JsonSerializerOptions options) => + options.TryGetTypeInfo(typeof(T), out var info) ? (JsonTypeInfo)info : GeneratedJson.TypeInfo(options, BuiltInConverters.For()); +} + +/// +/// Creates the converter of a : the one its closed type registered when it was +/// first touched. Public only so that a source-generated can +/// instantiate it; there is nothing to call. +/// +/// +/// Not : under Native AOT the converter type built that way has no +/// constructor to invoke, and a static field read by reflection is trimmed. The registry the struct's +/// static constructor fills is what works there (measured in spikes/MergePatch). +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class PatchJsonConverterFactory : JsonConverterFactory +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary Registry = new(); + + /// Called by 's static constructor, with the converter for that closed type. + internal static void Register(Type patch, JsonConverter converter) => Registry[patch] = converter; + + /// + public override bool CanConvert(Type typeToConvert) => + typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Patch<>); + + /// + [UnconditionalSuppressMessage("Trimming", "IL2059", + Justification = "The static constructor of Patch is kept wherever Patch is instantiated, which it is wherever it is serialized; measured under Native AOT in spikes/MergePatch and the consumer smoke test.")] + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + RuntimeHelpers.RunClassConstructor(typeToConvert.TypeHandle); + + return Registry.TryGetValue(typeToConvert, out var converter) + ? converter + : throw new NotSupportedException($"{typeToConvert} registered no JSON converter: its static constructor did not run."); + } +} diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index a86d349..46af9cb 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -173,6 +173,8 @@ does not accept a `Result`, and `MapError` turns the exception int ## Not wire types +The one wire type here is `Patch`, a merge patch's field (below). + Neither `Option` nor `Result` serializes. System.Text.Json refuses both, in both directions, with a `NotSupportedException` that names the type and the alternative: @@ -331,6 +333,52 @@ Not refused on purpose: unassigned code points, since the runtime's Unicode tabl standard and a name spelled with a newly assigned character would be refused until the SDK caught up; and confusable letters across scripts, which is a comparison question and not a rejection. +## Merge patches + +A PATCH body in JSON Merge Patch (RFC 7396) says three things about a field: nothing, so it stays; +`null`, so it is cleared; or a value. System.Text.Json cannot tell the first two apart, since it +calls no converter for a property it does not find, so every PATCH endpoint rebuilt the distinction +by hand. `Patch` is that field, and the one wire type here, beside `Option`, which refuses JSON: +an option says whether a value exists, a patch whether a request mentioned it. + +```csharp +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); +``` + +```csharp +var patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; +var name = patch.Name; // Absent, the field was not mentioned +var nickname = patch.Nickname; // Null, the field was null +var quantity = patch.Quantity; // Value: 2 +``` + +`default(Patch)` is `Absent`, the one `default` of this package that means something, and the +analyzers leave it alone. Applying one needs no reflection: + +```csharp +patch.Name.IfValue(name => order.Name = name); // a clear cannot reach a required field +patch.Nickname.IfPresent(nickname => order.Nickname = nickname); // null clears an optional one +order.Quantity = patch.Quantity.Match( + quantity => quantity, + () => throw new InvalidOperationException("Quantity cannot be cleared."), + () => order.Quantity); +``` + +Reading never refuses a null: the Null state is part of the wire format whatever `T` is, and whether +a clear is accepted is the application's rule, said in `Match`. So a `Patch` over a struct is +never needed, and a value object inside a patch keeps its own rule: `Patch` given a value +`Create` refuses is refused as anywhere else, the input not quoted. Writing an absent patch is +refused, since a converter cannot leave a property out; a property that is written takes +`[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]`, and an absent patch is left out. + +Under a source-generated `JsonSerializerContext`, the context never sees `T`, since the patch's +converter hides it: a primitive, a string, a `Guid`, a date or a `Uri` is read with the serializer's +built-in converter and a value object with its own, and any other type, a class or an enum, is +listed in the context, or the serializer refuses with its own message naming it. The OpenAPI document +describes a patch property as `T`'s schema, not required, admitting null +([CodoMetis.TypeKit.AspNetCore](https://www.nuget.org/packages/CodoMetis.TypeKit.AspNetCore)). This is +not ASP.NET Core's JSON Patch (RFC 6902, a list of operations). + ## The analyzers They arrive with this package, so whoever can see the interfaces gets the guard: @@ -483,7 +531,7 @@ stored. ## Where things are -`Option`, `Result`, `OrderId.New()`, `AsText()`, `AsIdentifier()`, `[RequireCustomInitialization]`, `[RequireParameterlessConstructor]` and the type-parameter attributes `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` live in `CodoMetis.TypeKit`; +`Option`, `Result`, `OrderId.New()`, `AsText()`, `AsIdentifier()`, `Patch`, `[RequireCustomInitialization]`, `[RequireParameterlessConstructor]` and the type-parameter attributes `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` live in `CodoMetis.TypeKit`; every value-object contract in `CodoMetis.TypeKit.ValueObjects`. What only the generated code and the satellites call is in `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense. Targets .NET 10. Source and issues: [github.com/CaffeinatedCoder/CodoMetis.TypeKit](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit). diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/PatchIsDefaultableTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/PatchIsDefaultableTests.cs new file mode 100644 index 0000000..c26e9ed --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/PatchIsDefaultableTests.cs @@ -0,0 +1,34 @@ +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// Patch<T> is the one struct of the base package whose default means something: +/// absent. It carries no [RequireCustomInitialization], so the no-default rules leave it alone. +/// +public sealed class PatchIsDefaultableTests +{ + private const string Source = + """ + using CodoMetis.TypeKit; + + public sealed class Update + { + public Patch Name { get; set; } + + public Patch Written() => default; + public Patch Made() => new(); + public Patch[] Slots() => new Patch[3]; + } + """; + + [Fact] + public Task CMTK0001_is_silent_on_a_default_patch() => + new AnalyzerTest(Source).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task CMTK0005_is_silent_on_an_array_of_patches() => + new AnalyzerTest(Source).RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task CMTK0006_is_silent_on_an_unassigned_patch_member() => + new AnalyzerTest(Source).RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs new file mode 100644 index 0000000..8612802 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Nodes; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A property in the document: its argument's schema, inlined, never +/// required, admitting null. Measured first in spikes/MergePatch, in 3.1 and 3.0 and under +/// Native AOT, where a value object inside a patch has no contract and is described through what it wraps. +/// +public sealed class PatchSchemaTests +{ + public sealed record OrderPatch(Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines); + + public sealed record Lines(int Count); + + public sealed record Control(string? Name, int Quantity, ProbeCode Code, ProbeId Id, Lines? Lines); + + public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; + + private static Task DocumentAsync(OpenApiSpecVersion version) => + ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, endpoints: app => + { + app.MapPatch("/patched/{id}", int (int id, OrderPatch patch) => 0); + app.MapPost("/control", int (Control control) => 0); + }); + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_patch_property_is_its_argument_s_schema_inlined_and_admits_null(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + + foreach (var property in new[] { "name", "quantity", "code", "id" }) + { + var patch = document.Property(nameof(OrderPatch), property); + var control = document.Resolve(document.Property(nameof(Control), property)); + + patch["$ref"].ShouldBeNull($"'{property}' should be inlined, but was {patch.ToJsonString()}"); + patch["description"]!.GetValue().ShouldStartWith("A JSON Merge Patch field"); + AdmitsNull(patch, version).ShouldBeTrue($"'{property}' should admit null: {patch.ToJsonString()}"); + WithoutNull(patch).ShouldDescribeTheSameAs(WithoutNull(control), property); + } + } + + [Theory] + [MemberData(nameof(Versions))] + public async Task No_patch_property_is_required(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + + var required = document.Component(nameof(OrderPatch))["required"]?.AsArray().Select(node => node!.GetValue()) ?? []; + required.ShouldBeEmpty(); + + document.Component(nameof(Control))["required"].ShouldNotBeNull("the control has required properties, so the patch's emptiness means something"); + } + + private static bool AdmitsNull(JsonNode schema, OpenApiSpecVersion version) => + version == OpenApiSpecVersion.OpenApi3_1 + ? schema["type"] is JsonArray types && types.Any(type => type!.GetValue() == "null") + : schema["nullable"]?.GetValue() == true; + + /// The schema without its null admission and the patch's own description, for comparing with the control. + private static JsonNode WithoutNull(JsonNode schema) + { + var copy = JsonNode.Parse(schema.ToJsonString())!.AsObject(); + copy.Remove("nullable"); + copy.Remove("description"); + if (copy["type"] is JsonArray types) + { + var kept = types.Where(type => type!.GetValue() != "null").Select(type => type!.GetValue()).ToArray(); + copy["type"] = kept.Length == 1 ? kept[0] : new JsonArray([.. kept.Select(type => (JsonNode)type)]); + } + if (copy["anyOf"] is JsonArray anyOf) + { + var kept = anyOf.Where(option => option!["enum"] is null && option["nullable"] is null).Select(option => JsonNode.Parse(option!.ToJsonString())!).ToArray(); + if (kept.Length == 0) copy.Remove("anyOf"); else copy["anyOf"] = new JsonArray(kept); + } + return copy; + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs new file mode 100644 index 0000000..4d050b5 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using CodoMetis.TypeKit; + +namespace ReadmeSamples.Base.Patches; + +public sealed class Order +{ + public string Name { get; set; } = ""; + public string? Nickname { get; set; } + public int Quantity { get; set; } +} + +// sample: CodoMetis.TypeKit/patch +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); +// end sample + +public static class Applying +{ + public static void Apply(Order order, OrderPatch patch) + { + // sample: CodoMetis.TypeKit/patch-apply + patch.Name.IfValue(name => order.Name = name); // a clear cannot reach a required field + patch.Nickname.IfPresent(nickname => order.Nickname = nickname); // null clears an optional one + order.Quantity = patch.Quantity.Match( + quantity => quantity, + () => throw new InvalidOperationException("Quantity cannot be cleared."), + () => order.Quantity); + // end sample + } +} + +public sealed class PatchTests +{ + private const string Readme = "src/CodoMetis.TypeKit/README.md"; + + private static string Render(Patch patch) => patch.Match(value => $"Value: {value}", () => "Null", () => "Absent"); + + [Fact] + public void A_document_reads_as_the_comments_state() + { + // sample: CodoMetis.TypeKit/patch-read + var patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; + var name = patch.Name; // Absent, the field was not mentioned + var nickname = patch.Nickname; // Null, the field was null + var quantity = patch.Quantity; // Value: 2 + // end sample + + patch.Name.ShouldBeShownIn(Readme, Render); + patch.Nickname.ShouldBeShownIn(Readme, Render); + patch.Quantity.ShouldBeShownIn(Readme, Render); + _ = (name, nickname, quantity); + } + + [Fact] + public void Applying_sets_clears_and_keeps() + { + var order = new Order { Name = "Old", Nickname = "nick", Quantity = 1 }; + + Applying.Apply(order, JsonSerializer.Deserialize("""{"name":"New","nickname":null}""", JsonSerializerOptions.Web)!); + + order.Name.ShouldBe("New"); + order.Nickname.ShouldBeNull(); + order.Quantity.ShouldBe(1); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs b/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs index 52d31c5..11c3b96 100644 --- a/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NotWireTypeTests.cs @@ -299,8 +299,10 @@ public void A_source_generated_context_still_takes_a_converter_from_the_options( [Fact] public void Every_exported_struct_of_the_assembly_carries_the_refusing_converter() { + // Patch is the one wire type of the package, on purpose (PatchTests): it says what a request + // mentioned, which is what a document carries. var structs = typeof(Option<>).Assembly.GetExportedTypes() - .Where(type => type is { IsValueType: true, IsEnum: false }) + .Where(type => type is { IsValueType: true, IsEnum: false } && type != typeof(Patch<>)) .Select(Close) .ToList(); diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index 5c5f09b..bba30dc 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -70,6 +70,15 @@ public sealed class NullDelegateTests ["Option.FirstOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.FirstOrNone(Null>()), () => Array.Empty().FirstOrNone(Null>())], ["Option.LastOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.LastOrNone(Null>()), () => Array.Empty().LastOrNone(Null>())], + ["Patch.Match(Func, Func, Func): onValue"] = + [() => Patch.Value(1).Match(Null>(), () => 0, () => 0), () => Patch.Null().Match(Null>(), () => 0, () => 0), () => Patch.Absent().Match(Null>(), () => 0, () => 0)], + ["Patch.Match(Func, Func, Func): onNull"] = + [() => Patch.Value(1).Match(x => x, Null>(), () => 0), () => Patch.Null().Match(x => x, Null>(), () => 0), () => Patch.Absent().Match(x => x, Null>(), () => 0)], + ["Patch.Match(Func, Func, Func): onAbsent"] = + [() => Patch.Value(1).Match(x => x, () => 0, Null>()), () => Patch.Null().Match(x => x, () => 0, Null>()), () => Patch.Absent().Match(x => x, () => 0, Null>())], + ["Patch.IfValue(Action): action"] = [() => { Patch.Value(1).IfValue(Null>()); return null; }, () => { Patch.Null().IfValue(Null>()); return null; }, () => { Patch.Absent().IfValue(Null>()); return null; }], + ["Patch.IfPresent(Action): action"] = [() => { Patch.Value(1).IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], + // Catch has no branches: the delegate is checked before anything runs. Overloads that differ // only in type parameters are keyed with them. ["Result.Catch(Func): action"] = [() => Result.Catch(Null>())], diff --git a/test/CodoMetis.TypeKit.Tests/PatchTests.cs b/test/CodoMetis.TypeKit.Tests/PatchTests.cs new file mode 100644 index 0000000..2d6db34 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/PatchTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// , a JSON Merge Patch field: absent, null or a value. Measured first in +/// spikes/MergePatch, under the reflection resolver and a source-generated context, and under +/// Native AOT; each claim there is a test here, Native AOT in the consumer smoke test. +/// +public sealed class PatchTests +{ + private static readonly JsonSerializerOptions Reflection = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + private static readonly JsonSerializerOptions Generated = new(JsonSerializerDefaults.Web) { TypeInfoResolver = PatchJson.Default }; + + public static TheoryData Resolvers => ["reflection", "source-generated"]; + + private static JsonSerializerOptions Options(string resolver) => resolver == "reflection" ? Reflection : Generated; + + private static string Show(Patch patch) => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); + + [Fact] + public void Default_is_absent_and_the_states_are_told_apart() + { + Patch absent = default; + + absent.State.ShouldBe(PatchState.Absent); + Patch.Absent().ShouldBe(absent); + Patch.Null().State.ShouldBe(PatchState.Null); + Patch.Value(3).State.ShouldBe(PatchState.Value); + Patch.Value(null).State.ShouldBe(PatchState.Null, "a null value is the Null state"); + } + + [Fact] + public void Match_TryGetValue_IfValue_and_IfPresent_follow_the_state() + { + var seen = new List(); + + Show(Patch.Value("Ada")).ShouldBe("Value(Ada)"); + Show(Patch.Null()).ShouldBe("Null"); + Show(Patch.Absent()).ShouldBe("Absent"); + + Patch.Value("Ada").TryGetValue(out var value).ShouldBeTrue(); + value.ShouldBe("Ada"); + Patch.Null().TryGetValue(out _).ShouldBeFalse(); + + Patch.Value("Ada").IfValue(seen.Add); + Patch.Null().IfValue(seen.Add); + Patch.Absent().IfValue(seen.Add); + seen.ShouldBe(["Ada"]); + + seen.Clear(); + Patch.Value("Ada").IfPresent(v => seen.Add(v ?? "")); + Patch.Null().IfPresent(v => seen.Add(v ?? "")); + Patch.Absent().IfPresent(v => seen.Add(v ?? "")); + seen.ShouldBe(["Ada", ""]); + } + + [Fact] + public void ToOption_is_Some_for_a_value_and_None_for_a_clear_or_an_absence() + { + Patch.Value(3).ToOption().ShouldBe(Option.Some(3)); + Patch.Null().ToOption().IsNone().ShouldBeTrue(); + Patch.Absent().ToOption().IsNone().ShouldBeTrue(); + } + + [Fact] + public void Equality_is_by_state_and_value_and_ToString_prints_the_state_only() + { + Patch.Value("secret").ShouldBe(Patch.Value("secret")); + Patch.Value("secret").ShouldNotBe(Patch.Value("other")); + Patch.Null().ShouldNotBe(Patch.Absent()); + + Patch.Value("secret").ToString().ShouldBe("Patch { State = Value }"); + Patch.Absent().ToString().ShouldBe("Patch { State = Absent }"); + } + + [Theory] + [MemberData(nameof(Resolvers))] + public void An_absent_property_reads_as_Absent_a_null_as_Null_and_a_value_as_Value(string resolver) + { + var options = Options(resolver); + + var absent = JsonSerializer.Deserialize("{}", options)!; + Show(absent.Name).ShouldBe("Absent"); + Show(absent.Quantity).ShouldBe("Absent"); + Show(absent.Code).ShouldBe("Absent"); + Show(absent.Address).ShouldBe("Absent"); + + var nulls = JsonSerializer.Deserialize("""{"name":null,"quantity":null,"code":null,"address":null}""", options)!; + Show(nulls.Name).ShouldBe("Null"); + Show(nulls.Quantity).ShouldBe("Null"); + Show(nulls.Code).ShouldBe("Null"); + Show(nulls.Address).ShouldBe("Null"); + + var values = JsonSerializer.Deserialize("""{"name":"Ada","quantity":3,"code":"ABC","address":{"city":"Oslo"}}""", options)!; + Show(values.Name).ShouldBe("Value(Ada)"); + Show(values.Quantity).ShouldBe("Value(3)"); + Show(values.Code).ShouldBe("Value(ABC)"); + Show(values.Address).ShouldBe("Value(Address { City = Oslo })"); + } + + /// A type with a converter of its own, a value object's shape, is read with it, whatever the resolver; what it refuses is refused as anywhere else, the input not quoted. + [Theory] + [MemberData(nameof(Resolvers))] + public void A_type_with_its_own_converter_inside_a_patch_is_read_with_it_and_its_refusal_stands(string resolver) + { + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"code":"abc"}""", Options(resolver))); + + refusal.Message.ShouldContain("Code refused"); + refusal.Message.ShouldNotContain("abc"); + } + + [Theory] + [MemberData(nameof(Resolvers))] + public void Writing_leaves_an_absent_patch_out_under_WhenWritingDefault_and_refuses_it_otherwise(string resolver) + { + var options = Options(resolver); + + JsonSerializer.Serialize(new Outgoing(Patch.Null(), Patch.Absent()), options).ShouldBe("""{"name":null}"""); + JsonSerializer.Serialize(new Outgoing(Patch.Value("Ada"), Patch.Value(2)), options).ShouldBe("""{"name":"Ada","quantity":2}"""); + + var refusal = Should.Throw(() => JsonSerializer.Serialize(new BareOutgoing(Patch.Absent()), options)); + refusal.Message.ShouldContain("WhenWritingDefault"); + } + + /// The patch's converter hides its argument from the source generator, so a class not listed in the context is the serializer's own refusal, naming the context. + [Fact] + public void A_class_the_source_generated_context_never_saw_is_refused_by_the_serializer_naming_the_context() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = WithoutAddressJson.Default }; + + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"address":{"city":"Oslo"}}""", options)); + + refusal.Message.ShouldContain(nameof(Address)); + refusal.Message.ShouldContain(nameof(WithoutAddressJson)); + } + + public sealed record Address(string City); + + /// A value object's shape: a struct with a converter of its own that refuses, naming the type and never the input. + [JsonConverter(typeof(CodeConverter))] + public readonly record struct Code + { + public string Value { get; } + public Code(string value) => Value = value == value.ToUpperInvariant() ? value : throw new JsonException("Code refused the JSON value (NotUpperCase)."); + public override string ToString() => Value; + } + + public sealed class CodeConverter : JsonConverter + { + public override Code Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => new(reader.GetString()!); + public override void Write(Utf8JsonWriter writer, Code value, JsonSerializerOptions options) => writer.WriteStringValue(value.Value); + } + + public sealed record Update(Patch Name, Patch Quantity, Patch Code, Patch Address); + + public sealed record UpdateWithoutAddress(Patch Address); + + public sealed record Outgoing( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Name, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Quantity); + + public sealed record BareOutgoing(Patch Name); +} + +// A class a patch wraps is listed, since the patch's converter hides it from the generator; a primitive +// and a type with its own converter need not be. +[JsonSerializable(typeof(PatchTests.Update))] +[JsonSerializable(typeof(PatchTests.Address))] +[JsonSerializable(typeof(PatchTests.Outgoing))] +[JsonSerializable(typeof(PatchTests.BareOutgoing))] +internal partial class PatchJson : JsonSerializerContext; + +[JsonSerializable(typeof(PatchTests.UpdateWithoutAddress))] +internal partial class WithoutAddressJson : JsonSerializerContext; diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index 962fe1c..76ad00b 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -695,6 +695,7 @@ await using var app = builder.Build(); app.MapOpenApi(); app.MapGet("/orders/{id}", (OrderId id, Quantity? limit) => new OrderView(id, [Quantity.From(1)], Amount.From(1.5m))); app.MapPost("/orders", (OrderView body) => body.Amount); +app.MapPatch("/orders/{id}", (OrderId id, OrderPatch patch) => $"{patch.Code.Match(code => $"Value({code.Value})", () => "Null", () => "Absent")} {patch.Note.Match(note => $"Value({note})", () => "Null", () => "Absent")}"); app.MapGet("/codes/{text}", (string text) => Code.Create(text).ToHttpResult()); await app.StartAsync(); @@ -712,6 +713,11 @@ Console.WriteLine($"openapi-parameter={document?["paths"]?["/orders/{id}"]?["get Console.WriteLine($"openapi-items={schemas?["OrderView"]?["properties"]?["quantities"]?["items"]?.ToJsonString() ?? "missing"}"); await Check("bound", async () => ((int)(await http.GetAsync($"/orders/{Guid.NewGuid()}?limit=3")).StatusCode).ToString()); await Check("malformed", async () => ((int)(await http.GetAsync("/orders/nope")).StatusCode).ToString()); +// A merge patch through the context: the patch's converter hides Code and string from the generator, +// so the value object reads with its own converter and the string with the serializer's built-in one. +await Check("patch", async () => await (await http.PatchAsync($"/orders/{Guid.NewGuid()}", new StringContent("""{"code":"ABC","note":null}""", System.Text.Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync()); +await Check("patch-refused", async () => ((int)(await http.PatchAsync($"/orders/{Guid.NewGuid()}", new StringContent("""{"code":"abc"}""", System.Text.Encoding.UTF8, "application/json"))).StatusCode).ToString()); +Console.WriteLine($"openapi-patch={schemas?["OrderPatch"]?["properties"]?["code"]?.ToJsonString() ?? "missing"} required={schemas?["OrderPatch"]?["required"]?.ToJsonString() ?? "none"}"); await Check("result-success", async () => await http.GetStringAsync("/codes/ABC")); await Check("result-fault", async () => { @@ -727,6 +733,8 @@ public sealed record Line(OrderId Id, Code Code, Name Name, Quantity Quantity, A public sealed record OrderView(OrderId Id, List Quantities, Amount Amount); +public sealed record OrderPatch(Patch Code, Patch Note); + public sealed record Holder(Option Value); // Lists no wrapped type: the generated converters must not need one. @@ -738,6 +746,7 @@ internal partial class ShopJson : JsonSerializerContext; // The web host's: what the endpoints exchange, and what their value objects wrap, for the schemas. [JsonSerializable(typeof(OrderView))] +[JsonSerializable(typeof(OrderPatch))] [JsonSerializable(typeof(Amount))] [JsonSerializable(typeof(Code))] [JsonSerializable(typeof(string))] @@ -858,6 +867,9 @@ assert_contains 'result-success="ABC"' "$work/aot.out" "ToHttpResult assert_contains "result-fault=422 application/problem+json NotUpperCase" "$work/aot.out" "and a fault as problem details with its mapped status" assert_contains "result-responses=200,400,422" "$work/aot.out" "the document lists the value and each mapped status" assert_contains "malformed=400" "$work/aot.out" "a malformed route parameter is a 400" +assert_contains 'patch=Value(ABC) Null' "$work/aot.out" "a Patch reads a value object and a null through the context under Native AOT" +assert_contains "patch-refused=400" "$work/aot.out" "a value object a patch wraps still refuses" +assert_contains 'openapi-patch={"type":["null","string"],"description":"A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it."} required=none' "$work/aot.out" "a patch property is its wrapped type's schema, inlined and nullable, and not required" assert_contains "ef-insert=1" "$work/aot.out" "EF Core writes value objects through the compiled model" assert_contains "ef-value=2" "$work/aot.out" ".Value translates in a precompiled query" assert_contains "ef-parameter=ABC" "$work/aot.out" "a value-object parameter is mapped at run time" From 979d8e50d3c63ea156cd6387dec21363281509b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:44:10 +0200 Subject: [PATCH 013/147] spike: ClosedSets, run and answered: a closed set as a generated type on Metalama 2026.1 and .NET 10 Name from the field through a BeforeTypeConstructor initializer, All in declaration order, Match with one delegate per member, Parse and JSON by name, a private constructor where none is declared. One workaround: Func cannot be built from the method builder's type parameter, so the template's own parameter type is copied per member. Co-Authored-By: Claude Fable 5.1 --- spikes/ClosedSets/.gitignore | 2 + spikes/ClosedSets/ClosedSets.slnx | 4 + spikes/ClosedSets/Directory.Build.props | 11 + spikes/ClosedSets/README.md | 49 ++++ spikes/ClosedSets/Spike.App/Program.cs | 34 +++ spikes/ClosedSets/Spike.App/Sets.cs | 31 +++ spikes/ClosedSets/Spike.App/Spike.App.csproj | 8 + .../Spike.Generators/ClosedSetAspect.cs | 212 ++++++++++++++++++ .../Spike.Generators/ClosedSetFabric.cs | 14 ++ .../ClosedSets/Spike.Generators/IClosedSet.cs | 15 ++ .../Spike.Generators/Spike.Generators.csproj | 5 + 11 files changed, 385 insertions(+) create mode 100644 spikes/ClosedSets/.gitignore create mode 100644 spikes/ClosedSets/ClosedSets.slnx create mode 100644 spikes/ClosedSets/Directory.Build.props create mode 100644 spikes/ClosedSets/README.md create mode 100644 spikes/ClosedSets/Spike.App/Program.cs create mode 100644 spikes/ClosedSets/Spike.App/Sets.cs create mode 100644 spikes/ClosedSets/Spike.App/Spike.App.csproj create mode 100644 spikes/ClosedSets/Spike.Generators/ClosedSetAspect.cs create mode 100644 spikes/ClosedSets/Spike.Generators/ClosedSetFabric.cs create mode 100644 spikes/ClosedSets/Spike.Generators/IClosedSet.cs create mode 100644 spikes/ClosedSets/Spike.Generators/Spike.Generators.csproj diff --git a/spikes/ClosedSets/.gitignore b/spikes/ClosedSets/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/spikes/ClosedSets/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/spikes/ClosedSets/ClosedSets.slnx b/spikes/ClosedSets/ClosedSets.slnx new file mode 100644 index 0000000..11cdd3b --- /dev/null +++ b/spikes/ClosedSets/ClosedSets.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/spikes/ClosedSets/Directory.Build.props b/spikes/ClosedSets/Directory.Build.props new file mode 100644 index 0000000..e525bb6 --- /dev/null +++ b/spikes/ClosedSets/Directory.Build.props @@ -0,0 +1,11 @@ + + + + net10.0 + 14 + enable + enable + 2026.1.29 + 0.0.1-spike + + diff --git a/spikes/ClosedSets/README.md b/spikes/ClosedSets/README.md new file mode 100644 index 0000000..b52b07e --- /dev/null +++ b/spikes/ClosedSets/README.md @@ -0,0 +1,49 @@ +# Spike: can a closed set be a generated type with static members, on Metalama 2026.1 and .NET 10? + +**Status: run 2026-10-13, answered.** From the "closed-set (smart enum) family" part of phase 13 +(2.0.0) in [docs/plan.md](../../docs/plan.md) §2. Decision 44 pins 2.0.0 to `net10.0` and Metalama +2026.1, so the family is a generated type with static members, not a C# 15 union; this spike is the +measurement that nothing in it needs anything newer. + +**Question.** A member is a `public static readonly` field of the set's own type, with constant data +through a private constructor the author writes, or none. Can a Metalama 2026.1 aspect, applied by +a transitive fabric as the value-object aspects are, derive the rest: `Name` from the field, `All` +in declaration order, `Match` with one required delegate per member named after it, `TryParse` and +`Parse` by name, `ToString`, `QualifiedName`, a JSON converter writing the name and reading it back +as the member itself, a private constructor where none is declared, and the interface's static +abstract members satisfied so generic code can use any set? + +**Answer: yes, all of it, on .NET SDK 10.0.401 and Metalama 2026.1.29**, with one workaround. The +names are assigned by an initializer injected before the static constructor +(`InitializerKind.BeforeTypeConstructor`), which runs after the static field initializers, so a +member's `Name` needs no `nameof` at the declaration. `Match`'s parameters are added per member in +`buildMethod`; `Func` cannot be built from the method builder's own type parameter +(Metalama asserts "the reference to ITypeSymbol being null is not supported yet"), so the template +declares one `Func` parameter, whose bound type the other members' parameters copy, and the +first is renamed. Everything else is the plain advising API the value-object aspects already use. + +## The candidate + +`Spike.Generators` holds `IClosedSet` (the contract), `ClosedSetAspect` and a transitive +fabric; `Spike.App` declares `OrderStatus` (no data, no constructor) and `OrderFault` (a kind and a +remedy per member through a private constructor) and runs the claims. `dotnet build ClosedSets.slnx` +then `dotnet run --no-build --project Spike.App`. + +## Findings + +| # | Claim | Result | +|---|---|---| +| 1 | `All` holds the members themselves, in declaration order | measured: `Open,Shipped,Cancelled`, `All[1]` is `Shipped` | +| 2 | `Name` comes from the field, assigned after the static field initializers; `QualifiedName` and `ToString` follow | measured: `OrderFault.NotFound`, `Cancelled` | +| 3 | Constant data per member through the author's private constructor, optional | measured: a kind and a remedy, one remedy null | +| 4 | `Match` has one required delegate per member, named after it in camel case | measured: `Match(open:, shipped:, cancelled:)` picks the branch; built through the template-parameter workaround above | +| 5 | `TryParse` and `Parse` by name; an unknown name is `false` or a `FormatException` naming the type, never the input | measured | +| 6 | JSON writes the name and reads the member itself back; an unknown name is a `JsonException` naming the type, never the input | measured, through a nested converter class and `[JsonConverter]` introduced by the aspect | +| 7 | Members are singletons: `==` is reference equality and `Parse` returns the member | measured | +| 8 | The interface's static abstract members are generated, so `T.All` works in generic code | measured: `Names()` | +| 9 | A set without a constructor gets a private one, so `= new()` in a member compiles and nothing outside can construct one | measured: `OrderStatus` builds and runs | +| 10 | Diagnostics: not partial, not sealed, no member, a public constructor, a public static property of the set's type | written (SPIKE001–005); held by the generators' tests once ported | + +**What the port adds beyond this spike:** the fault bits (a kind per member, an optional sentence, +read by the fault responses of step 8), the discovery attribute the satellites read, the OpenAPI +string enum and the EF column by name, and the CMTK ids for the diagnostics. diff --git a/spikes/ClosedSets/Spike.App/Program.cs b/spikes/ClosedSets/Spike.App/Program.cs new file mode 100644 index 0000000..39f8ca2 --- /dev/null +++ b/spikes/ClosedSets/Spike.App/Program.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Spike.App; + +var failures = 0; +void Claim(int number, string text, bool holds) +{ + Console.WriteLine($"{(holds ? "PASS" : "FAIL")} {number,2} {text}"); + if (!holds) failures++; +} + +Claim(1, $"All in declaration order: {string.Join(",", OrderStatus.All.Select(s => s.Name))}", string.Join(",", OrderStatus.All.Select(s => s.Name)) == "Open,Shipped,Cancelled"); +Claim(1, $"All holds the members themselves: {ReferenceEquals(OrderStatus.All[1], OrderStatus.Shipped)}", ReferenceEquals(OrderStatus.All[1], OrderStatus.Shipped)); +Claim(2, $"Name from the field, set after the static field initializers: {OrderStatus.Open.Name}, {OrderFault.Shipped.Name}", OrderStatus.Open.Name == "Open" && OrderFault.Shipped.Name == "Shipped"); +Claim(2, $"QualifiedName and ToString: {OrderFault.NotFound.QualifiedName}, {OrderStatus.Cancelled}", OrderFault.NotFound.QualifiedName == "OrderFault.NotFound" && OrderStatus.Cancelled.ToString() == "Cancelled"); +Claim(3, $"constant data through the author's constructor: {OrderFault.Shipped.Kind} '{OrderFault.Shipped.Remedy}' / {OrderFault.Empty.Remedy ?? ""}", OrderFault.Shipped.Kind == FaultKind.Conflict && OrderFault.Empty.Remedy is null); +Claim(4, $"Match with one delegate per member: {OrderStatus.Shipped.Match(open: () => "o", shipped: () => "s", cancelled: () => "c")}", OrderStatus.Shipped.Match(() => "o", () => "s", () => "c") == "s"); +Claim(5, $"TryParse/Parse by name: {OrderStatus.TryParse("Shipped", out var parsed)} {parsed?.Name}; unknown: {OrderStatus.TryParse("Nope", out _)}", OrderStatus.TryParse("Shipped", out parsed) && ReferenceEquals(parsed, OrderStatus.Shipped) && !OrderStatus.TryParse("Nope", out _)); +try { OrderStatus.Parse("Nope"); Claim(5, "Parse of an unknown name throws", false); } +catch (FormatException e) { Claim(5, $"Parse of an unknown name throws FormatException naming the type, not the input: {e.Message}", e.Message.Contains("OrderStatus") && !e.Message.Contains("Nope")); } +var json = JsonSerializer.Serialize(new Holder(OrderStatus.Shipped, OrderFault.Empty)); +Claim(6, $"JSON writes the name: {json}", json == """{"Status":"Shipped","Fault":"Empty"}"""); +var back = JsonSerializer.Deserialize(json)!; +Claim(6, $"and reads back the member itself: {ReferenceEquals(back.Status, OrderStatus.Shipped)} {ReferenceEquals(back.Fault, OrderFault.Empty)}", ReferenceEquals(back.Status, OrderStatus.Shipped) && ReferenceEquals(back.Fault, OrderFault.Empty)); +try { JsonSerializer.Deserialize("""{"Status":"Nope"}"""); Claim(6, "unknown name refused", false); } +catch (JsonException e) { Claim(6, $"an unknown name is refused, the input not quoted: {e.Message[..Math.Min(80, e.Message.Length)]}", e.Message.Contains("OrderStatus") && !e.Message.Contains("Nope")); } +Claim(7, $"members are singletons: {OrderStatus.Open == OrderStatus.Parse("Open")}", OrderStatus.Open == OrderStatus.Parse("Open")); +Claim(8, $"the interface is satisfied generically: {Names()}", Names() == "NotFound,Shipped,Empty"); + +static string Names() where T : class, Spike.Generators.IClosedSet => string.Join(",", T.All.Select(m => m.Name)); + +Console.WriteLine(failures == 0 ? "ALL PASS" : $"{failures} FAILED"); +return failures; + +public sealed record Holder(OrderStatus Status, OrderFault Fault); diff --git a/spikes/ClosedSets/Spike.App/Sets.cs b/spikes/ClosedSets/Spike.App/Sets.cs new file mode 100644 index 0000000..eabed88 --- /dev/null +++ b/spikes/ClosedSets/Spike.App/Sets.cs @@ -0,0 +1,31 @@ +using Spike.Generators; + +namespace Spike.App; + +/// Plain: no data, no constructor; the generators add a private one. +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public enum FaultKind { NotFound, Conflict, Invalid } + +/// With constant data per member, through a private constructor the author writes. +public sealed partial class OrderFault : IClosedSet +{ + public static readonly OrderFault NotFound = new(FaultKind.NotFound, "The order does not exist."); + public static readonly OrderFault Shipped = new(FaultKind.Conflict, "The order has shipped and cannot change."); + public static readonly OrderFault Empty = new(FaultKind.Invalid, null); + + private OrderFault(FaultKind kind, string? remedy) + { + Kind = kind; + Remedy = remedy; + } + + public FaultKind Kind { get; } + + public string? Remedy { get; } +} diff --git a/spikes/ClosedSets/Spike.App/Spike.App.csproj b/spikes/ClosedSets/Spike.App/Spike.App.csproj new file mode 100644 index 0000000..f5bda72 --- /dev/null +++ b/spikes/ClosedSets/Spike.App/Spike.App.csproj @@ -0,0 +1,8 @@ + + + Exe + + + + + diff --git a/spikes/ClosedSets/Spike.Generators/ClosedSetAspect.cs b/spikes/ClosedSets/Spike.Generators/ClosedSetAspect.cs new file mode 100644 index 0000000..963debc --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/ClosedSetAspect.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Metalama.Framework.Advising; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; +using Metalama.Framework.Code.DeclarationBuilders; +using Metalama.Framework.Code.SyntaxBuilders; +using Metalama.Framework.Diagnostics; + +namespace Spike.Generators; + +[CompileTime] +internal sealed class ClosedSetArguments +{ + public required INamedType Set { get; init; } + public required IReadOnlyList Members { get; init; } +} + +internal sealed class ClosedSetAspect : TypeAspect +{ + private static readonly DiagnosticDefinition NotPartial = new("SPIKE001", Severity.Error, "'{0}' must be partial: the generators add its members."); + private static readonly DiagnosticDefinition NotSealed = new("SPIKE002", Severity.Error, "'{0}' must be sealed: a closed set has no subtypes."); + private static readonly DiagnosticDefinition NoMembers = new("SPIKE003", Severity.Error, "'{0}' declares no member: a public static readonly field of its own type."); + private static readonly DiagnosticDefinition PublicConstructor = new("SPIKE004", Severity.Error, "'{0}' has a public constructor, so instances beside its members can be made."); + private static readonly DiagnosticDefinition<(INamedType, string)> MemberIsNotAField = new("SPIKE005", Severity.Error, "'{0}.{1}' is a public static member of the set's type but not a readonly field; a member is a public static readonly field."); + +#pragma warning disable CS0649, CS8618 + [Template] public string _name; + [Template] public static IReadOnlyList _all; +#pragma warning restore CS0649, CS8618 + + public override void BuildAspect(IAspectBuilder builder) + { + var target = builder.Target; + var errors = false; + + void Report(DiagnosticDefinition definition, T args) { builder.Diagnostics.Report(definition.WithArguments(args)); errors = true; } + + if (!target.IsPartial) Report(NotPartial, target); + if (!target.IsSealed) Report(NotSealed, target); + + foreach (var constructor in target.Constructors.Where(c => !c.IsImplicitlyDeclared && c.Accessibility == Accessibility.Public)) + Report(PublicConstructor, target); + + var members = target.Fields + .Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(target)) + .ToList(); + + foreach (var field in members.Where(f => f.Writeability != Writeability.ConstructorOnly)) + Report(MemberIsNotAField, (target, field.Name)); + foreach (var property in target.Properties.Where(p => p.IsStatic && p.Accessibility == Accessibility.Public && p.Type.Equals(target))) + Report(MemberIsNotAField, (target, property.Name)); + + if (members.Count == 0) Report(NoMembers, target); + if (errors) { builder.SkipAspect(); return; } + + var args = new ClosedSetArguments { Set = target, Members = members }; + builder.Tags = args; + + // A set that declares no constructor has the implicit public one; a private parameterless one + // replaces it, so that `= new()` in a member keeps compiling and nothing outside can. + if (!target.Constructors.Any(c => !c.IsImplicitlyDeclared)) + builder.IntroduceConstructor(nameof(PrivateConstructorTemplate), buildConstructor: c => c.Accessibility = Accessibility.Private); + + builder.IntroduceField(nameof(_name), IntroductionScope.Instance, OverrideStrategy.Fail, f => f.Accessibility = Accessibility.Private); + builder.IntroduceProperty(nameof(NameTemplate), buildProperty: p => { p.Name = "Name"; p.Accessibility = Accessibility.Public; }); + builder.IntroduceProperty(nameof(QualifiedNameTemplate), buildProperty: p => { p.Name = "QualifiedName"; p.Accessibility = Accessibility.Public; }); + builder.IntroduceMethod(nameof(ToStringTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => m.Name = "ToString"); + + // The names: assigned once, after the static field initializers, before any static constructor body. + builder.AddInitializer(nameof(NamesInitializerTemplate), InitializerKind.BeforeTypeConstructor); + + // All, in declaration order, as a static field initialized from the members. + var listType = ((INamedType)TypeFactory.GetType(typeof(IReadOnlyList<>))).WithTypeArguments(target); + builder.IntroduceField(nameof(_all), IntroductionScope.Static, OverrideStrategy.Fail, f => + { + f.Accessibility = Accessibility.Private; + f.Type = listType; + f.Writeability = Writeability.ConstructorOnly; + f.InitializerExpression = ExpressionFactory.Parse($"new global::{target.FullName}[] {{ {string.Join(", ", members.Select(m => $"global::{target.FullName}.{m.Name}"))} }}"); + }); + builder.IntroduceProperty(nameof(AllTemplate), buildProperty: p => { p.Name = "All"; p.Type = listType; p.Accessibility = Accessibility.Public; p.IsStatic = true; }); + + // TryParse and Parse by name. + builder.IntroduceMethod(nameof(TryParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Accessibility = Accessibility.Public; + m.Parameters[1].Type = target.ToNullable(); + }); + builder.IntroduceMethod(nameof(ParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.Accessibility = Accessibility.Public; + m.ReturnType = target; + }); + + // Match: one required delegate per member, named after it. + // The template is generic itself, so TResult is a real type parameter; the parameters, one per + // member, are Func built from it. + // Func cannot be built from the builder's own type parameter (Metalama 2026.1 asserts: + // "the reference to ITypeSymbol being null is not supported yet"), so the template declares one + // such parameter, whose bound type the other members' parameters copy; the first is renamed. + builder.IntroduceMethod(nameof(MatchTemplate), IntroductionScope.Instance, OverrideStrategy.Fail, m => + { + m.Name = "Match"; + m.Accessibility = Accessibility.Public; + var func = m.Parameters[0].Type; + m.Parameters[0].Name = Camel(members[0].Name); + foreach (var member in members.Skip(1)) m.AddParameter(Camel(member.Name), func); + }); + + // JSON: the name as a string, read back by name; an unknown name refused without quoting it. + var converter = builder.IntroduceClass($"{target.Name}JsonConverter", buildType: t => + { + t.Accessibility = Accessibility.Public; + t.BaseType = ((INamedType)TypeFactory.GetType(typeof(JsonConverter<>))).WithTypeArguments(target); + t.IsStatic = false; + }); + converter.IntroduceMethod(nameof(JsonReadTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Read"; m.ReturnType = target; }); + converter.IntroduceMethod(nameof(JsonWriteTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Write"; m.Parameters[1].Type = target; }); + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(JsonConverterAttribute), constructorArguments: [converter.Target])); + } + + [CompileTime] + private static string Camel(string name) => char.ToLowerInvariant(name[0]) + name[1..]; + + [Template] public void PrivateConstructorTemplate() { } + + [Template] public string NameTemplate { get => meta.This._name; } + + [Template] public string QualifiedNameTemplate { get => meta.Target.Type.Name + "." + meta.This._name; } + + [Template] public string ToStringTemplate() => meta.This._name; + + [Template] + public static void NamesInitializerTemplate() + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var member in args.Members) + { + member.Value!._name = member.Name; + } + } + + [Template] public static dynamic? AllTemplate { get => meta.ThisType._all; } + + [Template] + public static bool TryParseTemplate(string name, out dynamic? member) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) + { + member = candidate.Value; + return true; + } + } + + member = null; + return false; + } + + [Template] + public static dynamic? ParseTemplate(string name) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value; + } + + throw new FormatException($"{args.Set.Name} has no member of that name."); + } + + [Template] + public TResult MatchTemplate(Func first) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var index = meta.CompileTime(0); + foreach (var member in args.Members) + { + if (ReferenceEquals(meta.This, member.Value)) return meta.Target.Parameters[index].Value!.Invoke(); + index++; + } + + throw new InvalidOperationException($"{args.Set.Name}: not a member."); + } + + [Template] + public dynamic? JsonReadTemplate(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var name = reader.GetString(); + if (name is not null) + { + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value; + } + } + + throw new JsonException($"{args.Set.Name} has no member of that name."); + } + + [Template] + public void JsonWriteTemplate(Utf8JsonWriter writer, dynamic? value, JsonSerializerOptions options) + { + writer.WriteStringValue((string)value!.Name); + } +} diff --git a/spikes/ClosedSets/Spike.Generators/ClosedSetFabric.cs b/spikes/ClosedSets/Spike.Generators/ClosedSetFabric.cs new file mode 100644 index 0000000..e4b8436 --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/ClosedSetFabric.cs @@ -0,0 +1,14 @@ +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; +using Metalama.Framework.Fabrics; + +namespace Spike.Generators; + +internal sealed class ClosedSetFabric : TransitiveProjectFabric +{ + public override void AmendProject(IProjectAmender amender) => + amender.SelectTypes(includeNestedTypes: true) + .Where(type => type.TypeKind == TypeKind.Class + && type.AllImplementedInterfaces.Any(i => i.IsGeneric && i.Definition.Equals(TypeFactory.GetType(typeof(IClosedSet<>))))) + .AddAspect(_ => new ClosedSetAspect()); +} diff --git a/spikes/ClosedSets/Spike.Generators/IClosedSet.cs b/spikes/ClosedSets/Spike.Generators/IClosedSet.cs new file mode 100644 index 0000000..a028094 --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/IClosedSet.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Spike.Generators; + +/// A closed set: every instance is a named public static readonly field of the type, and the generators derive the rest. +public interface IClosedSet where TSelf : class, IClosedSet +{ + string Name { get; } + + string QualifiedName { get; } + + static abstract IReadOnlyList All { get; } + + static abstract bool TryParse(string name, [MaybeNullWhen(false)] out TSelf member); +} diff --git a/spikes/ClosedSets/Spike.Generators/Spike.Generators.csproj b/spikes/ClosedSets/Spike.Generators/Spike.Generators.csproj new file mode 100644 index 0000000..03683b7 --- /dev/null +++ b/spikes/ClosedSets/Spike.Generators/Spike.Generators.csproj @@ -0,0 +1,5 @@ + + + + + From 780e6f85f9bb14a0a989aecc91b83148566e7dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:47:51 +0200 Subject: [PATCH 014/147] build: move the plan to design/, so docs/ holds only what a consumer reads docs/ becomes the home of the topic pages, every one compiled like a README (decision 45), and nothing in it is exempt. The plan and the design notes move to design/, which is a solution folder, and every reference follows: AGENTS.md's workflow, the workflows' comments, the smoke test, the packaging comments, the base README's design links and the spike READMEs. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/dotnet.yml | 4 ++-- .github/workflows/release.yml | 2 +- CodoMetis.TypeKit.slnx | 7 +++++++ Directory.Packages.props | 2 +- {docs => design}/plan.md | 18 +++++++++++++++++- spikes/FabricSpike/README.md | 2 +- spikes/FaultResponses/README.md | 2 +- spikes/ResultCatch/README.md | 2 +- spikes/TypeArgumentConstraints/README.md | 2 +- .../CodoMetis.TypeKit.Analyzers.csproj | 2 +- .../KnownGoodFromCallerAnalyzer.cs | 2 +- src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj | 2 +- src/CodoMetis.TypeKit/README.md | 4 ++-- .../AotCompatibilityTests.cs | 2 +- test/consumer-smoke-test.sh | 6 +++--- 15 files changed, 41 insertions(+), 18 deletions(-) rename {docs => design}/plan.md (99%) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b2437e3..a9a06a8 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -94,7 +94,7 @@ jobs: run: dotnet pack CodoMetis.TypeKit.slnx -c Release --no-build -o dist # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (docs/plan.md §11). + # and zlib (design/plan.md §11). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev @@ -143,7 +143,7 @@ jobs: global-json-file: global.json # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (docs/plan.md §11). + # and zlib (design/plan.md §11). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a029ed..03eee2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -136,7 +136,7 @@ jobs: run: dotnet pack CodoMetis.TypeKit.slnx -c Release --no-build -o dist # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (docs/plan.md §11). + # and zlib (design/plan.md §11). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev diff --git a/CodoMetis.TypeKit.slnx b/CodoMetis.TypeKit.slnx index a15dc37..0f481b2 100644 --- a/CodoMetis.TypeKit.slnx +++ b/CodoMetis.TypeKit.slnx @@ -24,7 +24,14 @@ + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 6a3f8b8..65a5ba6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ one is a release decision, not a dependency update. --> - + diff --git a/docs/plan.md b/design/plan.md similarity index 99% rename from docs/plan.md rename to design/plan.md index 11e025f..ffad005 100644 --- a/docs/plan.md +++ b/design/plan.md @@ -1133,10 +1133,13 @@ custom attributes. 5. **Performance.** Allocation tests hold the generated members to what the wrapped type allocates, and `benchmarks/` measures time against the wrapped types. The benchmarks are not a gate: they are run before a release and when a template changes. -6. **README samples** (2026-09-28). Every C# block of the READMEs is a region in +6. **README and docs samples** (2026-09-28; the `docs/` pages 2026-10-13, decision 45). Every C# + block of the READMEs and of the topic pages in `docs/` is a region in `test/CodoMetis.TypeKit.Samples`, which references the shipping projects as a consumer does, with the analyzer on. `ReadmeSampleTests` requires each block to equal exactly one region and each region to be shown, so a sample that stops compiling fails the build and an edit on one side fails the test. + A docs page is discovered by being in `docs/`, and the root README's "Documentation" table has to + link it. An output stated in a comment is asserted against the README's own comment, found by the expression the test evaluates. A pre-release audit had found samples that did not compile (a bare fault as a `Result`, `quantity.Value;` as a statement, an ellipsis as a lambda body), CMTK0006 on the @@ -1432,6 +1435,19 @@ Still open: the first release after it is stable, minor or major as the fabric measurement says. The `2.0.0-preview.N` branch of 2026-10-12 becomes a preview of 2.0.0 itself from `main`; the .NET 11 measurement is a 3.0.0 spike with no release attached. +45. **Topic pages in `docs/`, compiled like the READMEs; the plan in `design/`** (2026-10-13, phase + 13). The package READMEs stay the reference for each package, since nuget.org shows them and every + exception message and analyzer help link anchors into them. Guides, one topic each and written + around examples, are `docs/.md`, listed in the root README's "Documentation" table, which + `ReadmeSampleTests` reads: every page in `docs/` is compiled by being there (its samples are + `{file name}/{name}` in `test/CodoMetis.TypeKit.Samples/Docs`), must be linked from the table, + and the table links only pages that exist. The same code block is never in a README and a page, + since a block is compiled from exactly one region. A page's public-surface table (`option.md`, + `result.md`) is checked against the types by reflection, so a member added without a row fails. + The plan and the design notes moved to `design/`, so that `docs/` holds only what a consumer + reads and nothing there is exempt from compilation; `design/` is a solution folder. Eleven pages + ship with 2.0.0; `Patch`, the closed sets, the fault responses and public aspects get theirs + as they land. ## 9. Option and Result diff --git a/spikes/FabricSpike/README.md b/spikes/FabricSpike/README.md index cd31370..18ad894 100644 --- a/spikes/FabricSpike/README.md +++ b/spikes/FabricSpike/README.md @@ -2,7 +2,7 @@ **Question.** Can the value-object aspects be applied by a `TransitiveProjectFabric` shipped in the aspect package, so the package holding `IValue` / `IValidatedValue<,,>` needs no Metalama at all? -The answer decides the package boundaries in [docs/plan.md](../../docs/plan.md). +The answer decides the package boundaries in [design/plan.md](../../design/plan.md). **Naming (2026-09-27).** The spike predates the package names. `Spike.Abstractions` stands for `CodoMetis.TypeKit` and `Spike.ValueObjects` for `CodoMetis.TypeKit.Generators`. The spike keeps diff --git a/spikes/FaultResponses/README.md b/spikes/FaultResponses/README.md index 6e972fa..81ff766 100644 --- a/spikes/FaultResponses/README.md +++ b/spikes/FaultResponses/README.md @@ -1,7 +1,7 @@ # Spike: can a fault response carry what a client acts on, without giving up decision 38's checks? **Status: outline, not yet run.** Proposed 2026-10-12, from the "Fault responses a client can act -on" part of phase 13 (2.0.0) in [docs/plan.md](../../docs/plan.md) §2. The fault's kind is no longer +on" part of phase 13 (2.0.0) in [design/plan.md](../../design/plan.md) §2. The fault's kind is no longer a `FaultKind` attribute: it is a property of a smart-enum member, in the same phase. **Question.** Decision 38's `ToHttpResult()` answers a fault with its status and its bare name, and diff --git a/spikes/ResultCatch/README.md b/spikes/ResultCatch/README.md index 881fbc1..dce981e 100644 --- a/spikes/ResultCatch/README.md +++ b/spikes/ResultCatch/README.md @@ -1,7 +1,7 @@ # Spike: can an exception enter a `Result` through one factory, without a `Try` type? **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 -(2.0.0) in [docs/plan.md](../../docs/plan.md) §2, decision 43. §9 keeps `Try` and `Unit` out: +(2.0.0) in [design/plan.md](../../design/plan.md) §2, decision 43. §9 keeps `Try` and `Unit` out: a `Try` is `Result` under a second name, and `Result` is the value-less shape a `Unit` would stand in for. diff --git a/spikes/TypeArgumentConstraints/README.md b/spikes/TypeArgumentConstraints/README.md index 08b04e9..4d63de3 100644 --- a/spikes/TypeArgumentConstraints/README.md +++ b/spikes/TypeArgumentConstraints/README.md @@ -1,7 +1,7 @@ # Spike: can an attribute on a type parameter constrain its arguments where C# cannot? **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "Constraints a type parameter -cannot declare" part of phase 13 (2.0.0) in [docs/plan.md](../../docs/plan.md) §2, decision 43, and +cannot declare" part of phase 13 (2.0.0) in [design/plan.md](../../design/plan.md) §2, decision 43, and the CMTK0010 row of §10. **Question.** C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : diff --git a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj index be18670..316744c 100644 --- a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj +++ b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj @@ -2,7 +2,7 @@ netstandard2.0 diff --git a/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs index 77175c3..adadccb 100644 --- a/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs @@ -26,7 +26,7 @@ namespace CodoMetis.TypeKit.Analyzers; /// The call is recognised by syntax, since on a value object of the same project it does not bind /// where Metalama runs analyzers (). A value the code /// produced itself is not reported, since "a value the caller just produced" is -/// legitimate (docs/plan.md §10): a constant, a local, a field, a method's result, an interpolation. +/// legitimate (design/plan.md §10): a constant, a local, a field, a method's result, an interpolation. /// A parameter copied into a local first is therefore not seen. Info, because a private helper that /// only ever receives constants, and a test theory's parameters, are reported too. /// diff --git a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj index 0de00a7..c467245 100644 --- a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj +++ b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj @@ -21,7 +21,7 @@ ` above its fence; an analyzer diagnostic in a comment is no reason, the sample suppresses it around the region. + A page in `docs/` is compiled by being there and must be linked from the root README's + "Documentation" table; a package README is the reference for its package, a docs page the guide for + a topic, and the same code block is never in both. - **Silent is worse than broken.** A value object that compiles without being generated, an analyzer that stops firing, or a converter that materialises `default` are this repo's characteristic failures. Prefer a diagnostic or an exception to a plausible result. ## Workflow -1. Read `docs/plan.md` and the relevant spike README before starting. +1. Read `design/plan.md` and the relevant spike README before starting. 2. Run `dotnet test` after each change. 3. **Prove every fix by reverting it.** See `.claude/skills/verify-the-guard`. The same applies to a new analyzer test or a convention test: seed the defect it claims to catch. diff --git a/CHANGELOG.md b/CHANGELOG.md index 01605fb..b68ff65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,12 @@ Semantic Versioning reserves for a major, listed under Changed as they land. arrived, with the joiners refused too. Both answer `Result`, where `Blank` is the caller's to read as an error or as absence and `Hidden` is a refusal in both; a null receiver is `Blank`. A value object over either is one line, shown in the README. +- **Documentation**: topic pages under `docs/`, linked from the root README's "Documentation" table: + value objects, `Option`, `Result`, written text, type constraints, JSON, EF Core, ASP.NET Core, + Native AOT, logging and secrets, and testing. Every C# block in them is compiled and its stated + outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete + public surface, checked against the types. The package READMEs remain the reference for each + package. The plan moved from `docs/plan.md` to `design/plan.md`. ## 1.5.1 — 2026-10-11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12700cc..fdcac96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,9 @@ dotnet test --solution CodoMetis.TypeKit.slnx The EF Core tests start a PostgreSQL container, so the suite needs Docker. The smoke test publishes one consumer with Native AOT, which needs the platform's native toolchain: clang (Xcode's command-line tools on macOS; `clang` and `zlib1g-dev` on Debian and Ubuntu). Shipping projects live -under `src/` and test projects under `test/`. -[AGENTS.md](AGENTS.md) is the architecture guide and [docs/plan.md](docs/plan.md) the current +under `src/` and test projects under `test/`; the topic pages a consumer reads are in `docs/`, each +compiled like the READMEs, and the plan is in `design/`. +[AGENTS.md](AGENTS.md) is the architecture guide and [design/plan.md](design/plan.md) the current roadmap. Read both before changing `Option`/`Result`, an aspect, the fabric, the analyzer or a satellite. diff --git a/README.md b/README.md index db31ddc..fa796b7 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,31 @@ db.Customers.Where(c => c.Email.Value.EndsWith("@example.com")); // WHERE services.AddOpenApi(options => options.AddTypeKit()); // OrderId: {"type":"string","format":"uuid"} ``` +## Documentation + +The package READMEs above are the reference for each package: what it adds, its setup, and the +rules it holds to, which is also where every exception message and analyzer help link lands. The +pages below are the guides, one topic each, written around examples. Every C# block in them is +compiled and its stated outputs are tested, as the READMEs' are. + +| Page | Covers | +|---|---| +| [Value objects](docs/value-objects.md) | Declaring one, struct or class, identifiers, validated text and numbers, faults, which factory to call, your own members, generic code over value objects | +| [Option](docs/option.md) | Absence as a type: creating, matching, combining, converting at the boundary, and the complete public surface | +| [Result](docs/result.md) | Outcomes as a type: faults, pipelines, async chains, collecting results, an exception as an outcome, errors across layers, and the complete public surface | +| [Written text](docs/text.md) | `AsText()` and `AsIdentifier()`: what each cleans and refuses, a value object over either, a required and an optional field | +| [Type constraints](docs/type-constraints.md) | `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` on a type parameter, `[RequireParameterlessConstructor]` and its code fix | +| [JSON](docs/json.md) | What a value object writes and reads, refusals, stored documents, `Option` and `Result` at the wire, a converter of your own, source-generated contexts | +| [EF Core](docs/ef-core.md) | Columns, keys, conventions, queries and the SQL they become, seed data, reading back without validation, SQLite in tests | +| [ASP.NET Core](docs/aspnet-core.md) | Binding in minimal APIs and MVC, what a refused value answers, a `Result` as a response, the OpenAPI document, problem details | +| [Native AOT](docs/native-aot.md) | What is measured, the project settings, JSON contexts, OpenAPI, EF Core's compiled model, configuration binding, discovery without reflection | +| [Logging and secrets](docs/logging-and-secrets.md) | A value that must not be printed, what still carries it, Serilog, Microsoft.Extensions.Logging, refusals that never quote the input | +| [Testing](docs/testing.md) | Assertions for `Option` and `Result`, table-driven tests of `Create`, EF Core on SQLite, endpoints on the test server | + +Still to come in 2.0.0, each page written when its feature lands ([the plan](design/plan.md#2-phases), +phase 13): `Patch`, the closed-set (smart enum) family, fault responses a client can act on, and +public aspects. + ## What it holds to - **No `.Value` on `Option` or `Result`.** The content is reached through `Match`, `TryGetValue` @@ -212,7 +237,7 @@ the public API, the generated members of a value object, and the analyzer ids an the contract. A new analyzer rule arrives as a warning or a suggestion in a minor version, and becomes an error only in a major one. [CHANGELOG.md](CHANGELOG.md) lists what each version changed. The plan, the decisions and their -evidence are in [docs/plan.md](docs/plan.md), and the measurements that decided the design are in +evidence are in [design/plan.md](design/plan.md), and the measurements that decided the design are in [spikes/](spikes/). ## Building diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md new file mode 100644 index 0000000..fb2af08 --- /dev/null +++ b/docs/aspnet-core.md @@ -0,0 +1,168 @@ +# ASP.NET Core + +A value object binds from a route, a query, a header or a body through the members the generators +add, so an endpoint takes `CustomerId` where it took `Guid`, and every way in applies `Create`. +`CodoMetis.TypeKit.AspNetCore` adds what the host needs beyond that: the value object's schema in +the OpenAPI document, and a `Result` as the response. The +[package README](../src/CodoMetis.TypeKit.AspNetCore/README.md) is the reference for both; this page +follows a request through a minimal API and an MVC controller. + +```csharp +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.AspNetCore.Http.HttpResults; +``` + +```csharp +public readonly partial record struct CustomerId : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); +} + +public enum PageFault { NotPositive, TooLarge } + +public readonly partial record struct PageSize : IValidatedValue +{ + public static Result Create(int value) => + value <= 0 ? Result.Error(PageFault.NotPositive) : value > 100 ? Result.Error(PageFault.TooLarge) : new PageSize(value); +} + +public enum CustomerFault { NotFound, EmailTaken } + +public sealed record RegisterCustomer(Email Email, string Name); + +public sealed record CustomerDto(CustomerId Id, Email Email, string Name); +``` + +## Setup + +```csharp +builder.Services.AddOpenApi(options => options.AddTypeKit()); // the document: every value object as the schema of what it wraps + +builder.Services.AddFaultResponses(fault => fault switch // a Result as a response: the status per fault +{ + CustomerFault.NotFound => StatusCodes.Status404NotFound, + CustomerFault.EmailTaken => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), +}); +``` + +Nothing is registered per value object, and the host does not reference the generators. The +`Microsoft.AspNetCore.OpenApi` reference has to be direct, at 10.0.12 or later +([the README says why](../src/CodoMetis.TypeKit.AspNetCore/README.md#setup)). + +## Binding in a minimal API + +```csharp +app.MapGet("/customers/{id}", (CustomerId id) => customers.Find(id).ToHttpResult()); // a route parameter, through the generated TryParse +app.MapGet("/customers", (Email? email, PageSize? page) => customers.List(email, page)); // query parameters, optional +app.MapPost("/customers", (RegisterCustomer body) => customers.Register(body).ToHttpResult()); // a body, through the generated JSON converter +``` + +Route, query and header parameters bind through the generated `TryParse`, with the invariant +culture; a body through the generated JSON converter. A value object declared in the same project as +the endpoint trips ASP0020 on a route parameter, since the route analyzer reads the source before the +generators add `IParsable`; the binding is right, and the README shows the pragma that says so. + +## What a refused value answers + +| Request | Answer | +|---|---| +| `GET /customers/0199aaaa-…` | 200, or the problem the fault maps to | +| `GET /customers/not-an-id` | 400: `TryParse` refused the route value | +| `GET /customers?page=500` | 400: `Create` refused it, through `TryParse` | +| `POST /customers` with `"email": "nobody"` | 400: the `JsonException` names `Email` and `NoAt`, never the text, and is logged; the body is empty unless the host adds `UseStatusCodePages()` | +| `POST /customers` without `email` | 200 with a default `Email`, unless `RespectRequiredConstructorParameters` is set, then 400 | + +The last row is the one to act on: System.Text.Json calls no converter for a property the body +lacks, so it stays `default`, an instance that passed no factory. The README's +[Binding](../src/CodoMetis.TypeKit.AspNetCore/README.md#binding) shows the two lines that make both +minimal APIs and MVC answer 400 instead. + +## Binding in MVC + +```csharp +[ApiController] +[Route("mvc/customers")] +public sealed class CustomersController(ICustomers customers) : ControllerBase +{ + [HttpGet("{id}")] + public Results, FaultProblem> Get([FromRoute] CustomerId id) => customers.Find(id).ToHttpResult(); // the generated TypeConverter binds it + + [HttpPost] + public Results, FaultProblem> Post(RegisterCustomer body) => customers.Register(body).ToHttpResult(); +} +``` + +MVC binds a route or query value object through the generated `TypeConverter`, and a body through +the JSON converter as a minimal API does. The difference is the 400: MVC answers a refused body with +its validation problem details, whose message is the `JsonException`'s, so the client reads +`Email refused the JSON value (NoAt)` and never the text. + +## A Result as a response + +```csharp +public interface ICustomers +{ + Result Find(CustomerId id); + + Result Register(RegisterCustomer request); + + IReadOnlyList List(Email? email, PageSize? page); +} +``` + +`ToHttpResult()` answers a success with `200 OK` and its value, `204 No Content` for a +`Result`, and a fault with RFC 9457 problem details, whose status the host mapped in `Setup` +and whose `fault` member names the fault. The domain declares the fault type; only the host +references ASP.NET. A handler's outcome can be read without a host: + +```csharp +var response = customers.Find(CustomerId.New()).ToHttpResult(); + +bool notFound = response.Result is FaultProblem { Fault: CustomerFault.NotFound }; // true +``` + +```json +{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", "title": "Not Found", "status": 404, "fault": "NotFound", "traceId": "…" } +``` + +A host whose endpoint answers with a fault type nobody mapped does not start, and the exception names +the endpoint. `AddFaultResponses` calls `AddProblemDetails()`, so a `CustomizeProblemDetails` of the +host's own reaches these problems: + +```csharp +builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["service"] = "customers"); +``` + +## The document + +With `AddTypeKit()`, every value object is the schema ASP.NET publishes for the type it wraps, +wherever it appears. One in a body keeps a component named after it, `CustomerId` and `Email` below; +a route or query parameter is described inline, as ASP.NET describes a parameter of the wrapped type, +so `page` is an integer and `PageSize` has no component. The fault responses are listed on every +operation that returns the fault type: + +```json +"/customers/{id}": { "get": { + "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], + "responses": { + "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomerDto" } } } }, + "404": { "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } }, + "409": { "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } } +``` + +A rule belongs in `Create`, not on the property: a validation attribute on a value-object property +lands in the value object's shared component and then applies to every other use. + +## Native AOT + +The transformer and `ToHttpResult()` are trim- and AOT-safe, and the smoke test publishes a host with +both. What the host's `JsonSerializerContext` has to list for the document, and for the problem +details, is in [Native AOT](native-aot.md#openapi). diff --git a/docs/ef-core.md b/docs/ef-core.md new file mode 100644 index 0000000..a606e2a --- /dev/null +++ b/docs/ef-core.md @@ -0,0 +1,206 @@ +# EF Core + +`CodoMetis.TypeKit.EntityFrameworkCore` maps every value object to a column of the type it wraps, +with nothing registered per type, and makes `.Value` work in a LINQ query as it does in memory. The +project that hosts the `DbContext` references this package and the base package; the generators stay +in the domain project. The [package README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) +is the reference: setup, what maps, the provider notes. This page walks through a model. + +```csharp +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.EntityFrameworkCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +``` + +```csharp +public readonly partial record struct InvoiceNo : IValue; + +public readonly partial record struct CustomerId : IValue; + +public readonly partial record struct Amount : IValue; + +public readonly partial record struct Currency : IValue; + +public readonly partial record struct IssuedOn : IValue; + +public readonly partial record struct Discount : IValue; + +public enum ReferenceFault { Blank, NotUpperCase } + +public readonly partial record struct Reference : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(ReferenceFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(ReferenceFault.NotUpperCase); // a rule added after rows were written + + return new Reference(value); + } +} +``` + +## The model + +```csharp +public class Invoice +{ +#pragma warning disable CMTK0006 // the database assigns the key on insert + public InvoiceNo No { get; set; } // key: bigint, generated by the database +#pragma warning restore CMTK0006 + public required CustomerId Customer { get; set; } // uuid + public required Reference Reference { get; set; } // text, applies Create on input and is read back without it + public required Amount Total { get; set; } // numeric(18,2), from ConfigureConventions + public required Currency Currency { get; set; } // character varying(3), from ConfigureConventions + public required IssuedOn IssuedOn { get; set; } // date + public Discount? Discount { get; set; } // integer, nullable +} + +public sealed class BillingDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Invoices => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder builder) + { + builder.Properties().HavePrecision(18, 2); // by the value object's type: EF applies a convention by CLR type, so Properties() would miss it + builder.Properties().HaveMaxLength(3); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(invoice => invoice.No); + modelBuilder.Entity().HasData(new Invoice // seed data takes value objects as it takes primitives + { + No = InvoiceNo.From(1), + Customer = CustomerId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), + Reference = Reference.FromKnownGood("INV-1"), + Total = Amount.From(10m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 1, 1)), + }); + } +} +``` + +Every property maps as a scalar of the wrapped type, nullable where the property is, and a facet +configured on it, a precision, a length, a column type, is kept. `required` is what CMTK0006 asks +for: a value object that nothing assigns would be a `default` instance, and the key is the one +property the database fills in, which the pragma says. A model-wide convention is configured by the +value object's type, since EF applies `ConfigureConventions` by CLR type. + +## Keys + +A single-column key over an integer is generated on add, as a key of that integer type is: an +identity column on PostgreSQL and SQL Server, so switching an `int` key to a value object there +changes no schema. A key over a `Guid` is the application's to assign, `Id = OrderId.New()`, a version 7 +`Guid` that sorts by creation time; for EF to generate it, configure `ValueGeneratedOnAdd()`. A composite +key of value objects is `HasKey(x => new { x.Order, x.Line })`, as for primitives. + +```csharp +await using var connection = new SqliteConnection("Data Source=:memory:"); // one connection keeps the in-memory database alive +await connection.OpenAsync(); +await using var db = new BillingDb(new DbContextOptionsBuilder().UseSqlite(connection).UseTypeKit().Options); +await db.Database.EnsureCreatedAsync(); + +var invoice = new Invoice +{ + Customer = CustomerId.New(), + Reference = Reference.FromKnownGood("INV-2"), + Total = Amount.From(250m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 10, 13)), +}; +db.Invoices.Add(invoice); +await db.SaveChangesAsync(); + +long assigned = invoice.No.Value; // 2, assigned by the database; the seeded invoice is 1 +``` + +SQLite fills such a key in, but its provider marks a key `AUTOINCREMENT` only for an integer CLR +type, so a model that migrates on SQLite configures `UseAutoincrement()` on each value-object key +([the README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#what-maps) has the detail). +`EnsureCreated`, as above, needs nothing. + +## Queries + +`.Value`, `GetValue()` and `ValueOrNull()` translate to the column itself, so the wrapped type's own +operations are available on it, and a value object compares to a parameter or a column directly. The +SQL below is PostgreSQL's, pinned by a test: + +```csharp +db.Invoices.Where(i => i.Total.Value >= 100m && i.Currency == eur); // WHERE i."Total" >= 100.0 AND i."Currency" = @eur +db.Invoices.Where(i => i.Reference.Value.EndsWith("-1")); // WHERE i."Reference" LIKE '%-1' +db.Invoices.Where(i => i.IssuedOn.Value.Year == 2026); // WHERE date_part('year', i."IssuedOn")::int = 2026 +db.Invoices.Where(i => i.Discount.ValueOrNull() == null); // WHERE i."Discount" IS NULL +db.Invoices.Where(i => customers.Contains(i.Customer)); // WHERE i."Customer" = ANY (@customers) +db.Invoices.OrderByDescending(i => i.IssuedOn).ThenBy(i => i.No); +db.Invoices.Select(i => new { i.No, Total = i.Total.Value }); // the wrapped value projected +db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g.Sum(i => i.Total.Value) }); // an aggregate over the column +``` + +A table's column is re-typed, not cast, so an index on it still serves the query. The element of a +primitive collection, a property in a JSON column and a parameter are converted, which may add a +cast. Ordering in SQL follows the column's collation, while the generated comparison in memory is +ordinal for a string, as for any string column. + +## Reading back without validation + +A column is read through the value object's materializer, not through `Create`: a rule added later +must not make the rows written before it unreadable, and what the application wrote is trusted by +contract. Input still goes through `Create`, nothing in the package is reachable from a request, and +`Materialize` called anywhere else is CMTK0004. `Revalidate()` finds the rows today's rules refuse: + +```csharp +await db.Database.ExecuteSqlAsync($"UPDATE Invoices SET Reference = 'inv-2' WHERE No = 2"); // a row from before the upper-case rule +db.ChangeTracker.Clear(); + +var read = await db.Invoices.SingleAsync(i => i.No == InvoiceNo.From(2)); // maps, without Create +string verdict = read.Reference.Revalidate().Match(_ => "valid", fault => $"refused: {fault}"); // "refused: NotUpperCase" +string stored = read.Reference.Value; // "inv-2", as the row holds it +``` + +What to do with such a row is the application's decision: a migration that rewrites it, a report, or +a `Create` applied to the stored value and saved back. + +## Option and Result + +Neither is a column. EF cannot map an `Option` or a `Result` property, and the model says so when it +is built. An optional value is `T?` on the entity, `Discount?` above, and `ToOption()` lifts it where +the domain wants an option: + +```csharp +Option discount = read.Discount.ToOption(); // None +``` + +## Compiled models + +EF Core runs under Native AOT only through a compiled model and precompiled queries, and +`UseTypeKit()` works there: value objects map through the compiled model, `.Value` translates in +precompiled queries, a value-object parameter binds, and a key over an integer is generated by the +database, in the native binary. Three limitations of EF's own, described in the +[README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#compiled-models-and-native-aot): a +struct value object that wraps a reference type cannot be a property of a compiled model, since EF +cannot write its sentinel, so such a value object on an entity is a `sealed partial record`; a sealed +entity class does not compile in precompiled queries; and precompiled queries need the converter +`UseTypeKit()` composes, not one named in `HasConversion`. The commands and the project settings are +in [Native AOT](native-aot.md#ef-core). + +## SQLite in tests + +The block under [Keys](#keys) is the whole setup: one open `SqliteConnection` to `:memory:` keeps the +database alive for the test, `UseTypeKit()` on the options, `EnsureCreated` for the schema. EF's +in-memory provider is not an option: it has no relational type mapping, so nothing is mapped there. +The mapping is tested in this repository on SQLite and PostgreSQL, and every wrapped-type family makes +a round trip through a real PostgreSQL. + +## If something does not map + +- **A struct value object is stored as it is, or a class one fails the model**: the provider has no + relational type mapping. EF's in-memory provider does that; use SQLite. +- **NU1605 on restore**: a direct reference to an EF Core package below 10.0.12. Raise it. +- **A convention does not reach a value object**: it was configured by the wrapped type; + configure it by the value object's type. +- **Every migration repeats an `AlterColumn` on SQLite**: an integer key without `UseAutoincrement()`. +- **A `default` instance reaches `SaveChanges`**: a property nothing set. CMTK0006 points at it; + `required` closes it. diff --git a/docs/json.md b/docs/json.md new file mode 100644 index 0000000..4b7b36d --- /dev/null +++ b/docs/json.md @@ -0,0 +1,192 @@ +# JSON + +A value object's JSON is its wrapped type's, byte for byte, under whatever options the application +uses, as a value and as a dictionary key. The generated converter reads and writes through the +serializer's own contract for the wrapped type, so replacing a `Guid` property by an `OrderId` +changes no document, and only what the value object refuses changes. `Option` and `Result` are not +wire types and refuse System.Text.Json in both directions. The +[Generators README](../src/CodoMetis.TypeKit.Generators/README.md#json) and the +[base README](../src/CodoMetis.TypeKit/README.md#not-wire-types) are the reference; this page is the +set of situations. + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +``` + +```csharp +public readonly partial record struct Quantity : IValue; + +public readonly partial record struct Weekday : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record Shipment(Quantity Boxes, Weekday Day, Dictionary Plan); + +public sealed record Primitives(int Boxes, DayOfWeek Day, Dictionary Plan); +``` + +## The wrapped type's JSON + +```csharp +var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; +var shipment = new Shipment(Quantity.From(3), Weekday.From(DayOfWeek.Friday), new() { [Weekday.From(DayOfWeek.Friday)] = Quantity.From(3) }); +var primitives = new Primitives(3, DayOfWeek.Friday, new() { [DayOfWeek.Friday] = 3 }); + +string json = JsonSerializer.Serialize(shipment, options); // {"boxes":3,"day":"Friday","plan":{"Friday":3}} +bool parity = json == JsonSerializer.Serialize(primitives, options); // true, the same bytes as the primitives +Shipment quoted = JsonSerializer.Deserialize("""{"boxes":"3","day":"Friday","plan":{}}""", options)!; // "3" read as 3: the web defaults allow a quoted number +``` + +Whatever the options say about the wrapped type applies: a converter registered for it, number +handling, an enum converter, a dictionary key policy. A test in this repository compares the two +for every wrapped-type family under several sets of options. One addition of the converter's own: a +`DateTime` is written as UTC, a `Local` one converted and an `Unspecified` one taken as UTC with its +digits kept, so a stored instant reads back as the instant it was. + +## Refusals + +Every generated way in applies `Create`, and a refusal names the value object and the rule, or the +wrapped type it could not read, never the text, which can be a secret: + +```csharp +JsonSerializer.Deserialize("\"nobody\""); // JsonException naming Email and NoAt, never the text +JsonSerializer.Deserialize("\"many\""); // JsonException naming Quantity and Int32, with no inner exception that quotes it +JsonSerializer.Deserialize("null"); // JsonException naming Quantity and null, refused for a struct +``` + +The `JsonException` still carries its path, so a host knows which property. ASP.NET Core answers it +with 400; what the body of that response says is in [ASP.NET Core](aspnet-core.md#what-a-refused-value-answers). + +## Null and missing + +A JSON `null` is refused for a struct value object; a `sealed partial record` reads it as `null`, as +any class does, since the serializer calls no converter for it. A property the document lacks is not +refused either: the serializer calls no converter for an absent property and leaves it `default`, +which for a value object is an instance that passed no factory. + +```csharp +public sealed record Parcel(Quantity Boxes, Email? Contact); +``` + +```csharp +var parcel = JsonSerializer.Deserialize("""{"Contact":null}""")!; +int boxes = parcel.Boxes.Value; // 0, a missing property calls no converter and stays default + +var strict = new JsonSerializerOptions { RespectRequiredConstructorParameters = true }; +JsonSerializer.Deserialize("""{"Contact":null}""", strict); // JsonException naming Parcel and Boxes +``` + +So a shape says which value objects it needs: `required` on a property, or +`RespectRequiredConstructorParameters` on the options for a record's constructor parameters, as +above. `RespectNullableAnnotations` makes a `null` refused for a class value object whose property +is not declared nullable. CMTK0006 points at a property of a value object's type that nothing sets. + +## Stored documents + +The generated converter applies today's `Create`, which is right for input and wrong for a document +the application stored before a rule existed. `StoredJsonConverterFactory`, registered on that +store's options and nowhere near input, reads a value object without its rules, as EF Core reads a +column; `Revalidate()` then says which stored values today's rules refuse. + +```csharp +public enum CodeFault { NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) => + value == value.ToUpperInvariant() ? new ProductCode(value) : Result.Error(CodeFault.NotUpperCase); // the rule added after "abc" was stored +} +``` + +```csharp +var store = new JsonSerializerOptions { Converters = { new StoredJsonConverterFactory() } }; + +ProductCode stored = JsonSerializer.Deserialize("\"abc\"", store); // read without the rules +string state = stored.Revalidate().Match(_ => "still valid", fault => $"now refused: {fault}"); // "now refused: NotUpperCase" +JsonSerializer.Deserialize("\"abc\""); // JsonException naming ProductCode and NotUpperCase, the same document as input +``` + +## Option and Result at the wire + +Neither serializes. System.Text.Json refuses both, in both directions, with a `NotSupportedException` +that names the type and the alternative; without the refusal a `Some` would be written as `{}` and +read back as `None`, with nothing raised. A shape says absent with `T?`, and the option lives between +the shapes ([Option](option.md#at-the-boundary)); a result is matched to a response or a document +([ASP.NET Core](aspnet-core.md#a-result-as-a-response)). + +```csharp +public sealed record Profile(Option Nickname); +``` + +```csharp +JsonSerializer.Serialize(new Profile(Option.Some("Annie"))); // NotSupportedException naming Option and nullable, the alternative +``` + +A converter registered on the options takes precedence over the refusal, so an application that +wants `Option` on the wire writes one, closed over each `T` it needs, and registers it on those +options only: + +```csharp +public sealed class OptionAsNullableConverter : JsonConverter> where T : notnull +{ + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null + ? Option.None() + : Option.Some(JsonSerializer.Deserialize(ref reader, (JsonTypeInfo)options.GetTypeInfo(typeof(T)))!); // the typed overload: no reflection, so Native AOT keeps it + + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (value.TryGetValue(out var content)) JsonSerializer.Serialize(writer, content, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); + else writer.WriteNullValue(); + } +} +``` + +```csharp +var wire = new JsonSerializerOptions { Converters = { new OptionAsNullableConverter() } }; + +string written = JsonSerializer.Serialize(new Profile(Option.Some("Annie")), wire); // {"Nickname":"Annie"} +string absent = JsonSerializer.Serialize(new Profile(Option.None()), wire); // {"Nickname":null} +Profile back = JsonSerializer.Deserialize("""{"Nickname":null}""", wire)!; // None +``` + +A converter per closed type rather than a factory: a factory would build the converter for each `T` +by `MakeGenericType`, which Native AOT cannot always honour. A property absent from the document +still reaches no converter and stays `default`, a `None`, so `required` is what says the shape must +mention it. A source-generated context cannot describe a shape with an `Option?` property at all +and fails when it builds the contract, so declare `T?` there. + +## Source-generated contexts + +With a `JsonSerializerContext`, which Native AOT requires, list the value objects or the types that +hold them, not what they wrap: the generated converter builds the wrapped type's contract itself, +from a converter on the options, the type's own `[JsonConverter]` or the serializer's built-in one. +An enum-backed value object is the one exception, since a context's `UseStringEnumConverter` does not +reach an enum the context does not list: + +```csharp +[JsonSerializable(typeof(Shipment))] +internal partial class ShipmentJson : JsonSerializerContext; + +[JsonSerializable(typeof(Shipment))] +[JsonSerializable(typeof(DayOfWeek))] // listed, so the string enum converter reaches what Weekday wraps +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] +internal partial class NamedShipmentJson : JsonSerializerContext; +``` + +```csharp +string numbered = JsonSerializer.Serialize(shipment, ShipmentJson.Default.Shipment); // {"Boxes":3,"Day":5,"Plan":{"Friday":3}} +string named = JsonSerializer.Serialize(shipment, NamedShipmentJson.Default.Shipment); // {"Boxes":3,"Day":"Friday","Plan":{"Friday":3}} +``` + +What a context must list for the OpenAPI document is a different question, answered in +[Native AOT](native-aot.md#json-contexts). diff --git a/docs/logging-and-secrets.md b/docs/logging-and-secrets.md new file mode 100644 index 0000000..1679ce8 --- /dev/null +++ b/docs/logging-and-secrets.md @@ -0,0 +1,109 @@ +# Logging and secrets + +A value object over a secret, a token, a key, a PIN, is a string on its way to a log the moment it +is interpolated, destructured or quoted in an exception. Three things keep it out: a value object +prints what its `ToString()` says and nothing else reaches a string path; `Option` and `Result` print +nothing of their content; and no refusal, in JSON, parsing, `FromKnownGood` or the type converter, +quotes the input. The [Generators README](../src/CodoMetis.TypeKit.Generators/README.md#a-value-that-must-not-be-printed) +and the [Serilog README](../src/CodoMetis.TypeKit.Serilog/README.md) are the reference. The examples +import `CodoMetis.TypeKit` and `CodoMetis.TypeKit.ValueObjects`. + +## A value that must not be printed + +Declare `ToString()`. It is kept, and then no formatting interface is generated, so interpolation, +`string.Format`, `Convert.ToString`, a record that holds it and every Microsoft.Extensions.Logging +path reach it too, `[LogProperties]` included. + +```csharp +public readonly partial record struct AccessToken : IValue +{ + public override string ToString() => "AccessToken(***)"; // kept; no formatting interface is generated, so every string path reaches this +} + +public sealed record Session(AccessToken Token, string Name); +``` + +```csharp +var token = AccessToken.From("tok-123"); + +string interpolated = $"token {token}"; // "token AccessToken(***)" +string converted = Convert.ToString(token, CultureInfo.InvariantCulture)!; // "AccessToken(***)" +string record = new Session(token, "Ann").ToString(); // Session { Token = AccessToken(***), Name = Ann } +string json = JsonSerializer.Serialize(token); // "tok-123", a conversion, not display +``` + +What still carries the value is what must: its JSON, the type converter and the database column, +which are conversions; and the debugger, which shows `ToString()` but expands `Value`. A `Session` +written as JSON carries the token, so the shape that is logged is not the shape that is sent. + +## Serilog + +Serilog destructures an object, `{@Session}` or a record holding a value object, by reading its +public properties, and `Value` is public: without the satellite the token above is logged as +`{ Value: "tok-123" }`, past the `ToString()`. `Destructure.WithTypeKit()` logs every value object as +what it prints, masked or not, and nothing is registered per type: + +```csharp +using Serilog; +``` + +```csharp +var events = new ListLogSink(); // an ILogEventSink of your own that keeps the rendered messages +using var logger = new LoggerConfiguration() + .Destructure.WithTypeKit() + .WriteTo.Sink(events) + .CreateLogger(); + +logger.Information("Opened {@Session}", new Session(token, "Ann")); +string logged = events.Messages.Single(); // Opened {"Token":"AccessToken(***)","Name":"Ann","$type":"Session"} +``` + +An `OrderId` is then `"0199aaaa-…"` in the event rather than `{ Value: "0199aaaa-…" }`, and a list +or dictionary of value objects a list or dictionary of strings, which is what the host's queries +expect. + +## Microsoft.Extensions.Logging + +Every path prints `ToString()`: a message template, the logging source generator, `[LogProperties]`. + +```csharp +using var factory = LoggerFactory.Create(logging => logging.AddProvider(sink)); // sink: a provider of your own that keeps the formatted messages +ILogger log = factory.CreateLogger("sessions"); + +log.LogInformation("Opened for {Token}", token); +string message = sink.Messages.Single(); // "Opened for AccessToken(***)" +``` + +Microsoft.Extensions.Compliance classifies a logging method's parameter, not its type: a +`DataClassificationAttribute` on the value object's type makes the logging generator report +LOGGEN035 and generate nothing for the method. Classify the parameter. + +## Refusals never quote the input + +```csharp +public enum PinFault { TooShort } + +public readonly partial record struct Pin : IValidatedValue +{ + public static Result Create(string value) => + value.Length >= 4 ? new Pin(value) : Result.Error(PinFault.TooShort); +} +``` + +```csharp +Pin.Parse("12", null); // FormatException naming Pin and TooShort, never the digits +JsonSerializer.Deserialize("\"12\""); // JsonException naming Pin and TooShort + +var typed = "12"; +Pin.FromKnownGood(typed); // InvalidOperationException naming typed and TooShort, the expression and not what it held + +string printed = Option.Some(token).ToString(); // nothing of the token: an option prints no content, and neither does a result +``` + +A refusal names the value object and the rule, or the wrapped type it could not read, and carries no +inner exception that quotes the text: the wrapped type's own `FormatException` would. What reaches a +log through this package is therefore the type and the fault. The fault's text is yours, so keep the +input out of it ([Value objects](value-objects.md#faults)), and a `FromKnownGood` given a literal names +the literal, since the literal is the expression. ASP.NET's own 400 for a refused route value quotes +the input, as it does for a `Guid`; a body refused by the JSON converter does not +([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)). diff --git a/docs/native-aot.md b/docs/native-aot.md new file mode 100644 index 0000000..234a058 --- /dev/null +++ b/docs/native-aot.md @@ -0,0 +1,149 @@ +# Native AOT + +Everything in the packages works in a trimmed or Native AOT application, and every package builds +with the trim and AOT analyzers on, warnings as errors. That cannot see the code the generators weave +into a consumer, or tell that a run-time path still works, so the consumer smoke test publishes two +consumers with `PublishAot` on every CI build, a web host with JSON and OpenAPI and a console +application with EF Core's compiled model, and runs the native binaries. This page is what Native AOT +asks of an application that uses value objects, in one place; each package README has its own +section. + +## The project + +```xml + + true + false + + $(InterceptorsNamespaces);Microsoft.EntityFrameworkCore.GeneratedInterceptors + +``` + +```bash +dotnet publish -c Release +``` + +A warning that names a package (`CodoMetis.…`) or a woven member (your value object's namespace) is +a bug in this repository; please report it. A warning that names EF's `DbContext` constructor is +EF's, and its documentation covers it. + +## JSON contexts + +Native AOT serializes through a source-generated `JsonSerializerContext`. List the value objects, or +the types that hold them, not what they wrap: the generated converter builds the wrapped type's +contract itself, from a converter on the options, the type's own `[JsonConverter]` or the +serializer's built-in one. The one exception is an enum: a context's `UseStringEnumConverter` does not +reach an enum the context does not list, so an enum-backed value object is written as a number +unless the enum is listed too. [JSON](json.md#source-generated-contexts) shows both contexts and +their output. + +```csharp +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct Quantity : IValue; + +public sealed record OrderLine(OrderId Order, Quantity Quantity); + +[JsonSerializable(typeof(OrderLine))] // the shape; neither Guid nor int is needed for the JSON +internal partial class OrdersJson : JsonSerializerContext; +``` + +```csharp +string json = JsonSerializer.Serialize(new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)), OrdersJson.Default.OrderLine); // {"Order":"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee","Quantity":3} +OrderLine back = JsonSerializer.Deserialize(json, OrdersJson.Default.OrderLine)!; +bool roundTrip = back == new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)); // true +``` + +`Option` and `Result` refuse JSON there exactly as on the JIT, and `StoredJsonConverterFactory` +reads without the rules. A converter of your own over an open `T` has to be a converter per closed +type, not a factory: a factory builds the converter for each `T` by `MakeGenericType` +([JSON](json.md#option-and-result-at-the-wire)). + +## OpenAPI + +The document asks for something the JSON does not. ASP.NET builds a value object's schema from its +JSON contract for the wrapped type, and a context has no contract for a type the host never +serializes itself, so the host's context lists every type its value objects wrap as well. Without +one, generating the document fails with an `InvalidOperationException` that names the value object +and the `[JsonSerializable]` to add, rather than describing it as `{}`. The +[README](../src/CodoMetis.TypeKit.AspNetCore/README.md#native-aot) shows the context. A problem +response from `ToHttpResult()` needs `AddProblemDetails()`, which `AddFaultResponses` calls. + +## EF Core + +EF Core runs under Native AOT only through a compiled model and precompiled queries: + +```bash +dotnet ef dbcontext optimize --precompile-queries --nativeaot +``` + +`UseTypeKit()` works there, and the smoke test's console consumer inserts through an identity key, +filters on `.Value`, binds a value-object parameter and reads a stored value back without the rules, +in the native binary. EF's own limitations: a struct value object that wraps a reference type cannot +be a property of a compiled model, so such a value object on an entity is a `sealed partial record`; +a sealed entity class does not compile in precompiled queries; and a converter named in +`HasConversion` is not what the precompiled queries cast to, so the mapping stays `UseTypeKit()`'s +([EF Core](ef-core.md#compiled-models)). + +## Configuration binding + +`PublishAot` and `PublishTrimmed` turn on the configuration binding source generator, which binds +only what it can see: it does not bind a value object, and leaves the property `default`. Bind the +wrapped type and apply `Create`: + +```csharp +public enum HostFault { Blank } + +public readonly partial record struct SmtpHost : IValidatedValue +{ + public static Result Create(string value) => + string.IsNullOrWhiteSpace(value) ? Result.Error(HostFault.Blank) : new SmtpHost(value.Trim()); +} +``` + +```csharp +var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Smtp:Host"] = " mail.example.com " }).Build(); + +Result host = SmtpHost.Create(configuration["Smtp:Host"] ?? ""); // the wrapped type bound, then Create +string bound = host.Match(h => h.Value, fault => fault.ToString()); // "mail.example.com" +``` + +The reflection-based binder, outside Native AOT, binds a value object through its generated +`TypeConverter`; the line above works in both, which is the point of writing it. + +## Discovery without reflection + +The satellites recognise a value object at run time by the attribute the generators put on it, +`GeneratedValueObjectAttribute`, whose type arguments are constrained to the +value-object interface. Never by `GetInterfaces()`: trimming removes an interface nothing in the +application uses, and nothing uses `IValueObject`, so under Native AOT every value +object once looked like no value object at all. Code of your own that is handed a `Type` does the +same: + +```csharp +static string Describe(Type type) => + type.GetCustomAttribute() is { } valueObject + ? $"{valueObject.ValueObjectType.Name} wraps {valueObject.WrappedType.Name}" + : $"{type.Name} is no value object"; +``` + +```csharp +string described = Describe(typeof(OrderId)); // "OrderId wraps Guid" +string plain = Describe(typeof(Guid)); // "Guid is no value object" +``` + +Code that needs the two types as type parameters calls `Accept` with an `IValueObjectVisitor` +and is handed them, without `MakeGenericType`. The attribute lives in +`CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense because nothing but this kind of code +needs it. + +## What the smoke test measures + +`test/consumer-smoke-test.sh` packs the packages, installs them into throwaway consumers outside the +repository, publishes the web and the data consumer with Native AOT, keeps every trim or AOT warning +that names a package or a woven member, and asserts on the native binaries' output: JSON through a +context that lists no wrapped type, the generated converter applying `Create`, the OpenAPI +component, parameter and list schemas, a route parameter bound, EF Core's insert, filter, parameter +and identity key. It needs the platform's linker, clang, and zlib on Linux. An +`[UnconditionalSuppressMessage]` in a package needs a measured reason, and that consumer is the +measurement. diff --git a/docs/option.md b/docs/option.md new file mode 100644 index 0000000..f39396d --- /dev/null +++ b/docs/option.md @@ -0,0 +1,140 @@ +# Option + +`Option` is a value that may be absent. It has no `.Value`: the content is reached where +absence is handled, through `Match`, `TryGetValue` and the combinators, so a caller cannot read a +value that is not there. `T` is `notnull`, `Some(null)` throws, and a `default(Option)` is `None`, +so an option that reports a value always has one. `ToString()` never prints the content. It lives in +`CodoMetis.TypeKit`; the [base README](../src/CodoMetis.TypeKit/README.md#optiont) is its reference. + +The examples import `CodoMetis.TypeKit` and share a customer with an optional nickname and an optional address: + +```csharp +public readonly partial record struct CustomerId : IValue; + +public sealed record Address(string Town, string Country); + +public sealed record Customer(CustomerId Id, string Name, string Tier, Option Nickname, Option
Address); +``` + +## Creating one + +```csharp +Option found = customers.GetValueOrNone(ann.Id); // Some +Option unknown = customers.GetValueOrNone(CustomerId.New()); // None +Option gold = customers.Values.FirstOrNone(c => c.Tier == "gold"); // Some, the first match (or None) +Option nickname = ann.Nickname; // Some +Option parsed = int.TryParse(text, out var number) ? Option.Some(number) : Option.None(); // None, taking its type from the conditional +Option none = Option.None(); // where nothing supplies the type +``` + +`Option.Some(value)` and `Option.None()` build one by hand; `None()` takes its type from where it goes, +a `return`, a conditional beside a `Some`, a parameter, and where nothing supplies one, as with `var`, +`Option.None()` says it. The lookups do the rest: `GetValueOrNone(key)` on a dictionary, +`FirstOrNone()` and `LastOrNone()` on a sequence, with or without a predicate, and `ToOption()` on a +nullable, `int?` or `string?`, which turns a null into `None`. CMTK0009 points at `FirstOrDefault()` +and its kind, whose `default` for a value object or a `Result` is an instance that passed no factory. + +## Reaching the value + +```csharp +string greeting = found.Match(customer => $"Hello again {customer.Name}", () => "Hello"); // "Hello again Ann" +string tier = unknown.Map(customer => customer.Tier).Or("standard"); // "standard" +int? limit = parsed.OrNull(); // null, an int? for an Option +int sum = found.Map(customer => customer.Name.Length).OrDefault(); // 3, a struct's default for None +bool known = found.IsSome(); // true + +if (found.TryGetValue(out var customer)) visits.Add(customer.Id); // customer is non-null in the branch + +foreach (var each in unknown.AsEnumerable()) visits.Add(each.Id); // runs once for Some, never for None +``` + +`Match` takes both branches and returns what they return, so it is the one call that cannot forget +absence. `Or(fallback)` and `OrDefault()` unwrap with a fallback, `OrNull()` into a nullable, `int?` +for an `Option` and `string?` for an `Option`. `IsSome()` and `IsNone()` answer the +question alone, for a guard or an assertion, never for a branch that then reads the value. + +## Combining + +```csharp +Option discount = found.Filter(customer => customer.Tier == "gold").Map(_ => 0.1m); // Some, Ann is gold +Option country = found.Bind(customer => customer.Address).Map(address => address.Country); // Some, the Option
flattened and then mapped +Option<(string Name, string Town)> label = found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)); // Some, only when both are +Option seen = found.Tap(customer => visits.Add(customer.Id)); // runs for Some, hands the option on + +Option greeting = + from customer in found + from address in customer.Address // a later from sees the earlier ones + where address.Country == "DE" + select $"{customer.Name} in {address.Town}"; // Some +``` + +`Map` changes the value and keeps absence; `Bind` takes a selector that is itself optional, so an +`Option>` never appears; `Filter` turns a value that fails the predicate into `None`; +`Zip` combines two to six options into one value when every one of them is `Some`; `Tap` runs an +action on a value and returns the option unchanged. `Select`, `SelectMany` and `Where` make query +syntax work, and each `from` after the first sees the earlier ranges, as above. + +## At the boundary + +An option is not a wire type, not a column and not a route parameter: System.Text.Json refuses it in +both directions ([JSON](json.md#option-and-result-at-the-wire)), and EF Core cannot map it +([EF Core](ef-core.md#option-and-result)). A shape that crosses a boundary says absent with `T?`, and +the option lives between the shapes. + +```csharp +public sealed record CustomerDto(string Name, string? Nickname); // the wire says absent with null + +static Customer FromDto(CustomerDto dto) => new(CustomerId.New(), dto.Name, "standard", dto.Nickname.ToOption(), Option.None()); + +static CustomerDto ToDto(Customer customer) => new(customer.Name, customer.Nickname.OrNull()); +``` + +```csharp +Result required = customers.GetValueOrNone(CustomerId.New()).ToResult(LookupFault.Unknown); // Error: Unknown +``` + +`ToOption()` on the way in and `OrNull()` on the way out; `ToResult(error)` where absence is an error +the caller has to act on, which hands the rest to [Result](result.md). + +## When it is the wrong type + +- The caller has to know why: a [Result](result.md) carries the fault. `Email.TryFrom(text)` answers + valid or not; `Email.Create(text)` says which rule refused it. +- Zero to many: a collection, possibly empty, says it without a type. +- A value that is always there once set: a `required` property. An `Option` property on an entity + or a DTO is refused by EF Core and by the serializer; a `T?` there is the shape, and `ToOption()` + lifts it where the domain wants an option. + +## The surface + +Every public member, on `Option`, on the static class `Option`, and as extensions in the +`CodoMetis.TypeKit` namespace. `T` is `notnull` throughout. + +| Member | On | Returns | +|---|---|---| +| `Some(value)` | `Option` | `Option`; throws `ArgumentNullException` for null | +| `None()` | `Option` | a `None` marker that converts to any `Option` | +| `None()` | `Option` | `Option` | +| `ToOption()` | `T?` (a nullable struct or a reference) | `Option`: `None` for null | +| `FirstOrNone()`, `FirstOrNone(predicate)` | `IEnumerable` | the first element, or the first matching one; `None` for none | +| `LastOrNone()`, `LastOrNone(predicate)` | `IEnumerable` | the last, likewise | +| `GetValueOrNone(key)` | `IReadOnlyDictionary` | `Option` | +| `IsSome()`, `IsNone()` | `Option` | `bool` | +| `Match(onSome, onNone)` | `Option` | what the branch returns | +| `TryGetValue(out value)` | `Option` | `bool`; `value` is non-null when true | +| `Or(fallback)` | `Option` | `T` | +| `OrDefault()` | `Option` where `T : struct` | `T`, `default` for `None` | +| `OrNull()` | `Option` | `T?`: `int?` for an `Option`, `string?` for an `Option` | +| `AsEnumerable()` | `Option` | zero or one element | +| `Map(selector)` | `Option` | `Option`; throws if the selector returns null | +| `Bind(selector)` | `Option` | the `Option` the selector returns, or `None` | +| `Filter(predicate)` | `Option` | the same option, or `None` when the predicate fails | +| `Tap(action)` | `Option` | the same option, after running the action on a value | +| `Zip(b, …, selector)` | `Option` with two to six options | `Option`, `Some` only when every one is | +| `ToResult(error)` | `Option` | `Result`: the error for `None` | +| `Select`, `SelectMany`, `Where` | `Option` | query syntax over `Map`, `Bind` and `Filter` | +| implicit from `None` | `Option` | `None` | + +Equality is a record struct's: two options are equal when both are `None` or hold equal values. +A `default(Option)` is `None`, and the analyzer still refuses writing it (CMTK0001): say +`Option.None()`. diff --git a/docs/result.md b/docs/result.md new file mode 100644 index 0000000..1854c6b --- /dev/null +++ b/docs/result.md @@ -0,0 +1,251 @@ +# Result + +`Result` is the outcome of an operation: a value or an error of your own type. +`Result` is the outcome of a command that produces no value: a success or an error. Neither +has a `.Value` or an `.Error`: the outcome is reached through `Match`, `TryGetValue` and the +combinators, so a failure is handled where the value is used. Both live in `CodoMetis.TypeKit`; the +[base README](../src/CodoMetis.TypeKit/README.md#resultt-terror-and-resultterror) is their reference, +and [Option](option.md) is the type for absence without a reason. + +The examples import `CodoMetis.TypeKit` and are a checkout: a cart is found, reserved against stock, and charged. + +```csharp +public readonly partial record struct CartId : IValue; + +public readonly partial record struct OrderId : IValue; + +public enum CheckoutFault { CartUnknown, CartEmpty, OutOfStock, PaymentRefused } + +public sealed record Line(string Sku, int Quantity, decimal Price); + +public sealed record Cart(CartId Id, IReadOnlyList Lines); + +public sealed record Reservation(OrderId Id, decimal Total); + +public sealed record Receipt(OrderId Id, decimal Total); +``` + +## Two shapes + +```csharp +public Result Reserve(Cart cart) // a value, or a fault +{ + if (cart.Lines.Count == 0) return Result.Error(CheckoutFault.CartEmpty); + if (!InStock(cart)) return Result.Error(CheckoutFault.OutOfStock); + + return new Reservation(OrderId.New(), cart.Lines.Sum(line => line.Quantity * line.Price)); // a bare value is a success +} + +public Result Charge(decimal total) => // a success or a fault, and no value + total <= limit ? Result.Success() : CheckoutFault.PaymentRefused; // a bare error converts here, where nothing can be confused with it +``` + +A bare value converts to a valued success; an error goes through `Result.Error(...)`, because in a +`Result` a bare `5` could be either. `Result` has no value, so there a bare error +converts, and so does the `Result.Success()` marker. The fault type is yours: an enum for most +operations, a record where a fault carries the rule's data ([Value objects](value-objects.md#faults)). +It never carries the input. + +## Reaching the outcome + +```csharp +var reserved = checkout.Reserve(cart); + +string outcome = reserved.Match(reservation => $"reserved {reservation.Total}", fault => $"refused: {fault}"); // "reserved 30" +ResultState state = reserved.State; // Success + +if (reserved.TryGetValue(out var reservation, out _) && checkout.Charge(reservation.Total)) audit.Add("charged"); // a Result converts to bool; a valued one never does + +Option maybe = reserved.ToOption(); // the fault dropped, where only presence matters +``` + +`Match` takes both branches and returns what they return. `TryGetValue(out value, out error)` gives +the branch and the content in one call: `value` is non-null when it returns true, `error` when it +returns false. Only `Result` converts to `bool`, so `if (checkout.Charge(total))` reads as it +should; a valued result in an `if` would say whether the call succeeded where a reader expects its +value, and `if (await users.IsEmailTakenAsync(email))` over a `Result` would take the +branch for a success holding `false`. `State` tells the three states apart without throwing: +`Success`, `Error`, and `Uninitialized` for a `default`, which an array slot or an unassigned field +still produces and on which every branching member throws rather than inventing a `default(TError)`. + +## A pipeline + +```csharp +Result receipt = checkout.FindCart(cartId) // Result + .Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on the value: break it, and this is the error + .Bind(checkout.Reserve) // a step that can fail itself + .Map(reservation => new Receipt(reservation.Id, reservation.Total)) // a step that cannot + .Tap(r => audit.Add($"receipt {r.Total}")) // on a success only + .TapError(fault => audit.Add($"refused: {fault}")); // on an error only + +Result charged = receipt.Bind(r => checkout.Charge(r.Total)); // a command after a query + +string paid = receipt.Match(r => $"paid {r.Total}", fault => $"refused: {fault}"); // "paid 30" +string ledger = charged.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" +string empty = checkout.FindCart(emptyCartId).Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty).Bind(checkout.Reserve).Match(r => "reserved", fault => $"refused: {fault}"); // "refused: CartEmpty" +``` + +`Map`, `Bind`, `Tap` and `Ensure` run on a success and carry an error through unchanged; `MapError`, +`TapError` and `Recover` run on an error and carry a success through. The first error stops the chain, +and every step after it is skipped, which the `empty` line shows: `Reserve` never ran. `Recover` is +the error branch's `Bind`: its selector answers a success or another error, as a second chance: + +```csharp +Result settled = charged.Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(cartId) : fault); // Success +``` + +A lambda that returns a bare value on one branch and `Result.Error(...)` on the other needs its type +argument, `.Bind(r => r.Total > 0 ? r : Result.Error(CheckoutFault.CartEmpty))`, because C# +infers a lambda's return type from its body alone; `.Ensure(r => r.Total > 0, CheckoutFault.CartEmpty)` +says the same without one. + +## Across layers + +A fault names what the operation found. What a client is told is the host's decision, and the two +meet in `MapError`: + +```csharp +public enum ApiFault { NotFound, Invalid, Unavailable } + +static class Faults +{ + public static ApiFault ToApi(CheckoutFault fault) => fault switch + { + CheckoutFault.CartUnknown => ApiFault.NotFound, + CheckoutFault.CartEmpty or CheckoutFault.OutOfStock => ApiFault.Invalid, + CheckoutFault.PaymentRefused => ApiFault.Unavailable, + _ => throw new UnreachableException(), + }; +} +``` + +```csharp +Result forApi = receipt.MapError(Faults.ToApi); // Success, untouched; an error would be the ApiFault +``` + +In ASP.NET Core, `ToHttpResult()` turns the result into a response, the fault into problem details +with the status the host maps it to ([ASP.NET Core](aspnet-core.md#a-result-as-a-response)). + +## Async chains + +The asynchronous members continue a `Task>` without an `await` each, so a chain is awaited +once, at its end: + +```csharp +Result chargedAsync = await checkout.FindCartAsync(cartId) // Task> + .EnsureAsync(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on a pending result + .BindAsync(cart => checkout.ReserveAsync(cart)) // an asynchronous step + .MapAsync(reservation => reservation.Total) // a synchronous step on a pending result + .BindAsync(total => checkout.ChargeAsync(total)) // a command: Result remains + .TapErrorAsync(fault => audit.Add($"not charged: {fault}")); + +string asyncLedger = chargedAsync.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" +``` + +The asynchronous steps take callbacks that return a `Task` and wait for them, so what a callback +throws reaches the chain's `await`. A callback that returns a `ValueTask` needs `.AsTask()` or an +`async` lambda: `TapAsync` and `TapErrorAsync` would otherwise bind to their synchronous overload and +not wait, and `MapAsync` would hold the `ValueTask` as the value. + +## Collecting results + +```csharp +Result, CheckoutFault> lines = requested.Traverse(checkout.ToLine); // every request to a line, or the first fault +Result, CheckoutFault> all = reservations.Sequence(); // a list of results to a result of a list +Result amount = unitPrice.Zip(quantity, (price, count) => price * count); // both successes, or the first error +Result bulk = cart.Lines.FirstOrError(line => line.Quantity >= 10, CheckoutFault.OutOfStock); // the first match, or the error + +string summary = requested.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "2 lines" +string stopped = refused.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "refused: OutOfStock" +``` + +`Traverse`, `Sequence` and `Zip` (two to six results) stop at the first error, in sequence or +argument order, and `Traverse` calls its selector for nothing after it. None of them collects errors: +a fault type that is a list of your own is how several faults travel together. + +## An exception as an outcome + +A call that throws has no way into a `Result` except a `try`/`catch` written by hand. `Result.Catch` +runs the call and answers its value or the exception as the error, the one place an exception becomes +data: + +```csharp +Result url = Result.Catch(() => new Uri(text)); // Error: UriFormatException +Result narrowed = Result.Catch(() => new Uri(text)); // that type only; any other propagates +Result asFault = Result.Catch(() => new Uri(text)).MapError(_ => ApiFault.Invalid); // Error: Invalid +Result written = Result.Catch(() => File.WriteAllText(path, "receipt")); // Success, a command +Result read = await Result.CatchAsync(() => File.ReadAllTextAsync(path)); // Success: receipt +``` + +An exception is for the code that called, not for a client: `ToHttpResult()` does not accept a +`Result`, and `MapError` turns it into a fault that it does, as `asFault` shows. +`Catch` spells both type arguments, since C# infers all or none. `CatchAsync` awaits +the task, so an exception thrown after its first `await` is the error too; a delegate that returns a +`Task` given to `Catch` is a compile error naming `CatchAsync`, since the task's exception would be +thrown after `Catch` had returned. An `OperationCanceledException` passes through every overload: a +cancellation is not an outcome, and caught it would read as a failure that `Recover` then runs on. +`Map`, `Bind` and the rest are unchanged: a selector that throws propagates. + +## Query syntax + +```csharp +Result receipt = + from cart in checkout.FindCart(cartId) + from reservation in checkout.Reserve(cart) // each from sees the earlier ranges + select new Receipt(reservation.Id, reservation.Total); +``` + +`Select` and `SelectMany` make it work; there is no `where`, since a result has no error to give a +filtered-out value, which is what `Ensure(predicate, error)` is for. + +## The surface + +Every public member of `Result`, `Result`, the static class `Result`, and the +extensions in the `CodoMetis.TypeKit` namespace. `T` and `TError` are `notnull` throughout. + +**Creating** + +| Member | On | Returns | +|---|---|---| +| `Success()` | `Result` | a `Success` marker that converts to any `Result` | +| `Success(value)` | `Result` | a `Success` marker that converts to any `Result` | +| `Error(error)` | `Result` | an `Error` marker that converts to either shape | +| `Success(value)`, `Error(error)` | `Result` | the concrete result, where the type is spelled | +| `Success()`, `Error(error)` | `Result` | likewise | +| implicit from `T`, `Success`, `Error` | `Result` | a bare value is a success; a bare error is not, since it could be either | +| implicit from `TError`, `Success`, `Error` | `Result` | a bare error converts, and so does the marker | +| `Catch(action)`, `Catch(action)`, `Catch(action)` | `Result` | `Result` or `Result`; narrowed to one exception type | +| `CatchAsync(action)` and the same type-argument forms | `Result` | the same, awaiting the task | +| `ToResult(error)` | `Option` | `Result` | +| `FirstOrError(predicate, error)`, `LastOrError(predicate, error)` | `IEnumerable` | the first or last match, or the error | +| `Traverse(selector)` | `IEnumerable` | `Result, TError>`, or `Result` for a command selector; stops at the first error | +| `Sequence()` | `IEnumerable>`, `IEnumerable>` | the list of values, or the first error | + +**Reaching the outcome** + +| Member | On | Returns | +|---|---|---| +| `State` | both | `ResultState`: `Success`, `Error` or `Uninitialized` | +| `Match(onSuccess, onError)` | both | what the branch returns; `onError` may take the error or nothing | +| `TryGetValue(out value, out error)` | `Result` | `bool`; `value` non-null when true, `error` when false | +| `TryGetError(out error)` | `Result` | `bool` | +| implicit to `bool` | `Result` only | true for a success | +| `AsEnumerable()` | `Result` | zero or one element | +| `ToOption()` | `Result` | `Some(value)`, or `None` for an error | + +**Continuing** + +| Member | On | Does | +|---|---|---| +| `Map(selector)` | both | a new value on a success; `Result` from a `Result` | +| `Bind(selector)` | both | the result the selector returns on a success; a valued one can bind to a command | +| `Ensure(predicate, error)` | `Result` | a success that fails the predicate becomes that error | +| `MapError(selector)` | both | a new error type | +| `Recover(selector)` | both | the selector's result on an error, a success or another error | +| `Tap(action)`, `TapError(action)` | both | runs the action on that branch, hands the result on | +| `Zip(b, …, selector)` | `Result` with two to six results | one value from all, or the first error | +| `Select`, `SelectMany` | `Result` | query syntax over `Map` and `Bind` | +| `MapAsync`, `BindAsync`, `MapErrorAsync`, `RecoverAsync`, `EnsureAsync`, `TapAsync`, `TapErrorAsync` | both, and `Task>` | the same, taking or continuing a task; the chain is awaited once | + +Equality is a record struct's. `ToString()` prints the state and never the value or the error, so a +result is safe to log; the debugger shows both. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..94711f9 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,136 @@ +# Testing + +`Option` and `Result` have no `.Value`, so a test reaches the content through +`CodoMetis.TypeKit.Testing`, which hands it back and fails saying what the outcome was. The rest of +this page is the shape of the tests a codebase on value objects ends up writing: `Create` as a table, +the generated ways in against `Create`, EF Core on SQLite, and an endpoint on the test server. The +[Testing README](../src/CodoMetis.TypeKit.Testing/README.md) is the package's reference. + +```csharp +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.Testing; +using CodoMetis.TypeKit.ValueObjects; +``` + +```csharp +public enum CodeFault { Blank, NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(CodeFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(CodeFault.NotUpperCase); + + return new ProductCode(value.Trim()); + } +} +``` + +## The assertions + +```csharp +[Fact] +public void A_code_is_created_or_refused() +{ + ProductCode code = ProductCode.Create("ABC").ShouldBeSuccess(); // the value, for the test's own assertions + code.Value.ShouldBe("ABC"); + + ProductCode.Create("abc").ShouldBeError(CodeFault.NotUpperCase); // this error, or the test fails + CodeFault fault = ProductCode.Create(" ").ShouldBeError(); // any error, handed back + fault.ShouldBe(CodeFault.Blank); + + ProductCode.TryFrom("ABC").ShouldBeSome().ShouldBe(code); + ProductCode.TryFrom("abc").ShouldBeNone(); +} +``` + +```csharp +string failure = Result.Catch(() => ProductCode.Create("abc").ShouldBeSuccess()).Match(_ => "", error => error.Message); // Expected a success, but the Result is the error NotUpperCase. +``` + +A failure is a `TypeKitAssertionException`, which every framework reports as the test's failure, so +the package depends on nothing but `CodoMetis.TypeKit`; the assertions sit beside xUnit's `Assert`, +Shouldly or whatever the test already uses. A failure names the error, which is what a test needs +to see, and never prints a value, as `Option` and `Result` never do. An uninitialized result fails +every assertion, saying it is a default instance. + +## Create as a table + +The rules are the value object's contract, and a table says all of them in one place: + +```csharp +[Theory] +[InlineData("ABC", null)] +[InlineData(" ABC ", null)] +[InlineData("", CodeFault.Blank)] +[InlineData(" ", CodeFault.Blank)] +[InlineData("abc", CodeFault.NotUpperCase)] +public void Create_applies_each_rule(string input, CodeFault? refused) +{ + var created = ProductCode.Create(input); + + if (refused is { } fault) created.ShouldBeError(fault); + else created.ShouldBeSuccess().Value.ShouldBe(input.Trim()); +} +``` + +## The generated ways in agree with Create + +What the generators derive from `Create` is tested through behaviour, not presence: a build proves +`TryParse` exists, and only a test proves it refuses what `Create` refuses. + +```csharp +[Fact] +public void Every_way_in_applies_the_rules() +{ + ProductCode.TryParse("abc", null, out _).ShouldBeFalse(); + Should.Throw(() => ProductCode.Parse("abc", null)).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => JsonSerializer.Deserialize("\"abc\"")).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => ProductCode.FromKnownGood("abc")).Message.ShouldContain("NotUpperCase"); + ProductCode.FromKnownGood("ABC").Revalidate().ShouldBeSuccess(); +} +``` + +## EF Core on SQLite + +One open `SqliteConnection` to `:memory:`, `UseTypeKit()` on the options and `EnsureCreated` for +the schema; [EF Core](ef-core.md#keys) has the whole block, and [SQLite in tests](ef-core.md#sqlite-in-tests) +says why EF's in-memory provider cannot stand in. + +## An endpoint on the test server + +```csharp +public sealed record CodeRequest(ProductCode Code); +``` + +```csharp +[Fact] +public async Task A_refused_body_is_a_400() +{ + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + await using var app = builder.Build(); + app.MapPost("/codes", (CodeRequest request) => request.Code.Value); + await app.StartAsync(TestContext.Current.CancellationToken); + + var client = app.GetTestClient(); + var accepted = await client.PostAsJsonAsync("/codes", new { code = "ABC" }, TestContext.Current.CancellationToken); + var refused = await client.PostAsJsonAsync("/codes", new { code = "abc" }, TestContext.Current.CancellationToken); + + accepted.StatusCode.ShouldBe(HttpStatusCode.OK); + refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest); +} +``` + +The body went through the generated JSON converter, so the 400 is `Create`'s refusal; what the +response says, in a minimal API and in MVC, is in [ASP.NET Core](aspnet-core.md#what-a-refused-value-answers). + +## The analyzers in a test project + +The analyzers run in test projects as everywhere, so `default(ProductCode)` in a test is CMTK0001, +as it should be: a test that needs a default instance says so with a pragma around the line, which +is also what this repository's tests do. A comparison with `default` is a guard and is not reported +(`id.ShouldNotBe(default)`), nor is a `default` assigned to the `out` parameter of a `Try` method. A +dropped `Result` in a test is CMTK0003, so `_ = ProductCode.Create(input);` says the drop is meant. diff --git a/docs/text.md b/docs/text.md new file mode 100644 index 0000000..25d8760 --- /dev/null +++ b/docs/text.md @@ -0,0 +1,106 @@ +# Written text + +Every application that stores what a person typed trims it and refuses a blank. Few refuse a +zero-width space, a Hangul filler or NUL, which `IsNullOrWhiteSpace` lets through and `Trim` leaves +in place, and fewer compose it, so a name written with `ü` and the same name written with `u` and a +combining diaeresis are two rows of a unique index that render alike. Two extensions on `string` in +`CodoMetis.TypeKit` do all of it and answer with a `Result`. The +[base README](../src/CodoMetis.TypeKit/README.md#written-text) is their reference; this page is about +using them: which one, where, and inside a value object. The examples import `CodoMetis.TypeKit`. + +## Two guards + +| | `AsText()` | `AsIdentifier()` | +|---|---|---| +| For | text a person wrote: a name, a description, a comment | a value another system issued: a subject claim, a session id, a key | +| Trims | yes | yes | +| Folds every space separator to an ordinary space | yes | no | +| Composes to NFC, so one spelling per name | yes | no, since an issued value is compared against what its issuer sends next time | +| Refuses a character that renders as nothing | yes | yes | +| The two zero-width joiners | kept: they spell Persian, Arabic and Indic scripts and hold an emoji sequence together | refused: no issuer needs one | +| A null receiver | `Blank` | `Blank` | + +```csharp +Result composed = "Zoe\u0308".AsText(); // Success: Zoë, composed to NFC: one code point for ë +Result folded = "Jean\u2003Paul".AsText(); // Success: Jean Paul, an em space folded to a space +Result joined = "\U0001F469\u200D\U0001F4BB".AsText(); // Success: 👩‍💻, the joiner holds the sequence together +Result hidden = "Zoe\u200B".AsText(); // Error: Hidden, a zero-width space +Result filler = "\u3164".AsText(); // Error: Hidden, a Hangul filler that IsNullOrWhiteSpace accepts +Result issued = " a1b2-c3 ".AsIdentifier(); // Success: a1b2-c3, trimmed and otherwise as issued +Result forged = "sub\u200Dject".AsIdentifier(); // Error: Hidden, a joiner in an identifier +``` + +Both scan by `Rune`, so a character above the Basic Multilingual Plane is seen for what it is, and an +unpaired surrogate is `Hidden` rather than an exception, which `Normalize` and a database driver would +both throw on. Not refused on purpose: unassigned code points, since the runtime's Unicode tables lag +the standard and a name spelled with a newly assigned character would be refused until the SDK caught +up; and confusable letters across scripts, which is a comparison question and not a rejection. + +## Required and optional + +`TextFault` has two members because the caller decides what a blank means. A required field makes +`Blank` an error; an optional one makes it absence. `Hidden` is a refusal in both. + +```csharp +public sealed record ProfileRequest(string? Name, string? Note); // bound from a request: nothing enforces the non-nullability + +static Result Required(string? text) => text.AsText(); // Blank is the error for a required field + +static Result, TextFault> Optional(string? text) => + text.AsText() + .Map(clean => Option.Some(clean)) + .Recover(fault => fault == TextFault.Blank ? Option.None() : Result.Error(fault)); // Blank is absence, Hidden still a refusal +``` + +```csharp +string name = Required(request.Name).Match(text => text, fault => fault.ToString()); // "Blank" +string note = Optional(request.Note).Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "none" +string tagged = Optional("note\u200B").Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "Hidden" +``` + +A null is `Blank` because the extension is the door a bound request property goes through, and +binding enforces no non-nullability: a `string Name` in a record is null when the body lacked it. + +## A value object over the guard + +No value object ships with the guards. One over either is one line, named for the field rather than +for the guard, since `DisplayName` says more than `Text` and two fields of one type can be assigned to +each other. The guard composes with rules of your own: + +```csharp +public enum NameFault { Blank, Hidden, TooLong } + +public readonly partial record struct DisplayName : IValidatedValue +{ + public static Result Create(string value) => + value.AsText() + .MapError(fault => fault == TextFault.Blank ? NameFault.Blank : NameFault.Hidden) // the guard's faults become the field's + .Ensure(text => text.Length <= 50, NameFault.TooLong) // a rule of this field + .Map(text => new DisplayName(text)); +} + +public readonly partial record struct SubjectId : IValidatedValue +{ + public static Result Create(string value) => value.AsIdentifier().Map(id => new SubjectId(id)); +} +``` + +```csharp +string shown = DisplayName.Create(" Zoe\u0308\u2003Q ").Match(name => name.Value, fault => fault.ToString()); // "Zoë Q" +string refused = DisplayName.Create(new string('x', 51)).Match(name => name.Value, fault => fault.ToString()); // "TooLong" +bool equal = DisplayName.FromKnownGood("Mu\u0308ller") == DisplayName.FromKnownGood("M\u00FCller"); // true, one spelling after NFC +``` + +Every generated way into `DisplayName`, JSON, a route parameter, `TryFrom`, applies the guard and the +length rule, so a document or a request that carries a hidden character is refused with `Hidden` +before it reaches the domain. Where the guard is the whole rule, the fault type is `TextFault` itself, +as `SubjectId` shows. + +## Where it does not belong + +- **A password or a secret.** Composing or folding it changes what the person typed; compare it as + given, and never through a guard that would also put it in a fault. +- **Text another system will parse**, such as a path or a URL: it has its own rules, and trimming it + is already a change. +- **A comparison key.** Confusable letters across scripts and case folding are a comparison's + concern; the guard decides what is stored, not what is equal. diff --git a/docs/type-constraints.md b/docs/type-constraints.md new file mode 100644 index 0000000..9b15411 --- /dev/null +++ b/docs/type-constraints.md @@ -0,0 +1,130 @@ +# Type constraints + +C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` +or `record`, and no way to demand a parameterless constructor of a given accessibility. The base +package has attributes that say what C# cannot, and the analyzers report the type that breaks them, +at build time, in whichever project declares it. CMTK0010 and CMTK0011 are both errors; the +[Analyzers README](../src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0010) is their reference. The +examples import `CodoMetis.TypeKit`. + +## Constraints on a type parameter + +| Attribute | Accepts | Refuses | +|---|---|---| +| `[Abstract]` | an abstract class or record | a concrete type, and an interface | +| `[Sealed]` | a sealed class or record, a struct, an enum | a type that can be derived from | +| `[Record]` | a record class or record struct | a class or a struct | +| `[Concrete]` | a class, record or struct that can be instantiated | an abstract or static type, and an interface. No constructor is demanded, unlike `new()` | + +An aggregate whose events are a closed family, and whose identifier is a value object: + +```csharp +public abstract record AccountEvent; + +public sealed record Deposited(decimal Amount) : AccountEvent; + +public readonly partial record struct AccountId : IValue; + +public abstract class Aggregate<[Abstract] TEvent, [Sealed] TId> +{ + public List Pending { get; } = []; +} + +public sealed class Account : Aggregate; // fine: an abstract record, a struct + +public sealed class Ledger : Aggregate; // CMTK0010: Deposited is not abstract +``` + +The rule reports at every closed use of the generic: a base type as above, an implemented interface, +a field, a property, a parameter, a local, a `typeof`, and a call, whether its type arguments are +written or inferred. A written argument is reported where it is written; an inferred one at the call. + +```csharp +public static class Snapshots +{ + public static string Describe<[Record] TState>(TState state) => state!.ToString()!; // a record prints its members +} + +public sealed class Balance { public decimal Amount { get; init; } } + +public sealed record Statement(decimal Amount); +``` + +```csharp +string shown = Snapshots.Describe(new Statement(10m)); // "Statement { Amount = 10 }" +string hidden = Snapshots.Describe(new Balance()); // CMTK0010: Balance is not a record, inferred at the call +``` + +## Forwarding + +A type parameter given as the argument must carry the attribute itself, or the requirement would end +at the first forwarding parameter: + +```csharp +public abstract class Leaky : Aggregate; // CMTK0010: TEvent does not carry [Abstract] + +public abstract class Sound<[Abstract] TEvent> : Aggregate; // the requirement follows the chain + +public sealed class Sealed : Aggregate where T : struct; // struct proves [Sealed], and [Concrete] +``` + +`struct` proves `[Sealed]` and `[Concrete]`, and `new()` proves `[Concrete]`, so a parameter under +those C# constraints needs no attribute. The open generic inside its own body and an unbound +`typeof(Aggregate<,>)` are never reported. Reflection, `MakeGenericType`, is out of reach, and the +attributes carry no run-time check: the guarantee is the build. + +## A parameterless constructor + +A type the host creates by reflection, an endpoint, a module, an entity configuration, a job, needs +a parameterless constructor of a given accessibility, and C# checks that only through `new()`, which +demands a public one and only where a type parameter says so. `[RequireParameterlessConstructor]` on +the interface or base class says it once; CMTK0011 appears on the concrete type that lacks the +constructor, directly or through any chain of base classes and interfaces, and the code fix writes +it. + +```csharp +[RequireParameterlessConstructor] +public interface IJob +{ + Task RunAsync(CancellationToken cancellationToken); +} + +[RequireParameterlessConstructor(ConstructorAccessibility.Internal)] +public abstract class Plugin; + +public sealed class Nightly : IJob // fine: no constructor declared, so the implicit one is public +{ + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Report : IJob // CMTK0011: no parameterless constructor; the fix adds one +{ + public Report(TimeProvider clock) { } + + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Importer : Plugin; // CMTK0011: the implicit constructor is public, Plugin requires internal +``` + +| The type | Reported | The fix | +|---|---|---| +| declares no constructor, and `Public` is required | no: the implicit constructor is public | | +| declares no constructor, and another accessibility is required | at the type | adds `internal Importer() { }` | +| declares constructors, none parameterless | at the type | adds one | +| has a parameterless constructor of the wrong accessibility | at the constructor | changes it | +| has a primary constructor | at the type | none: every other constructor would chain to the primary one, with arguments only its author knows | +| is abstract, or an interface | no: only concrete descendants are checked | | + +A record's copy constructor does not count. The attribute is read from metadata, so the marked +interface can live in another package, and a host's contract reaches every implementation in every +project. The check is the feature: an aspect could introduce the constructor, but only in a project +that compiles with Metalama, and the error has to appear in one that does not. + +## Severity + +Both rules are errors, since the code is wrong whatever surrounds it: a type argument that breaks +what its parameter declares fails at the first `MakeGenericType` or reflection-driven construction, +at run time, where the build could have said so. Both arrived in 2.0.0, a major version, which is the +only kind that adds a rule at Error. The ids are public contract; the severity follows +`.editorconfig` as every rule's does (`dotnet_diagnostic.CMTK0010.severity = warning`). diff --git a/docs/value-objects.md b/docs/value-objects.md new file mode 100644 index 0000000..35c3077 --- /dev/null +++ b/docs/value-objects.md @@ -0,0 +1,271 @@ +# Value objects + +A value object wraps exactly one value, is equal to another when the wrapped values are equal, and +is declared in one line. The contracts live in `CodoMetis.TypeKit`, the generation in +`CodoMetis.TypeKit.Generators`, which the project that declares value objects references. This page +is about designing them: which shape to pick, how to write `Create`, what a fault should carry, and +which factory to call where. What is generated, member by member, and every build error are in the +[Generators README](../src/CodoMetis.TypeKit.Generators/README.md); the contracts are in the +[base README](../src/CodoMetis.TypeKit/README.md#value-objects). The examples import `CodoMetis.TypeKit` +and `CodoMetis.TypeKit.ValueObjects`. + +## Struct or class + +```csharp +public readonly partial record struct Sku : IValue; // a struct: never null, and default is CMTK0001 + +public sealed partial record Note : IValue; // a class: for a string column in an EF Core compiled model +``` + +A struct is the default: it cannot be null, it allocates nothing, and the analyzer refuses a +`default` of it. Declare a `sealed partial record` class instead in two cases. A property of an EF +Core compiled model cannot be a struct that wraps a reference type, since EF cannot write its sentinel +([EF Core](ef-core.md#compiled-models)). And a class reads a JSON `null` as `null`, where a struct refuses +it, which matters only for a shape that cannot say `Note?` ([JSON](json.md#null-and-missing)). Equality, +JSON, parsing and the column are the same for both. + +## Identifiers + +```csharp +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct InvoiceNo : IValue; +``` + +```csharp +var id = OrderId.New(); // a version 7 Guid, which sorts by creation time +var imported = OrderId.New(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // its timestamp, for a migrated record +var no = InvoiceNo.From(42); + +int version = id.Value.Version; // 7 +``` + +`New()` is an extension on every plain value object over a `Guid`, in the `CodoMetis.TypeKit` +namespace, so the file that calls it imports that. An identifier over an integer is what the database +assigns: `From` builds one from a known number, and EF Core fills a key in on insert +([EF Core](ef-core.md#keys)). Two identifiers of different types never compare: `orderId == customerId` +does not compile, and comparing their `.Value`s is CMTK0008. + +## Validated text + +`Create` is the one method you write. Every generated way in applies it: JSON, parsing, the type +converter, `TryFrom` and `FromKnownGood`. + +```csharp +public enum EmailFault { Blank, NoAt, TooLong } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(EmailFault.Blank); + + var normalised = value.Trim().ToLowerInvariant(); // equal addresses hold the same wrapped value + if (!normalised.Contains('@')) return Result.Error(EmailFault.NoAt); + if (normalised.Length > 254) return Result.Error(EmailFault.TooLong); + + return new Email(normalised); // the private constructor is generated + } + + public string Domain => Value[(Value.IndexOf('@') + 1)..]; // computed from Value: fine. A second field is CMTK1012 +} +``` + +```csharp +string lowered = Email.Create(" Ops@Example.com ").Match(email => email.Value, fault => fault.ToString()); // "ops@example.com" +bool same = Email.FromKnownGood("Ops@Example.com") == Email.FromKnownGood("ops@example.com"); // true +string domain = Email.FromKnownGood("ops@example.com").Domain; // "example.com" +``` + +Normalise in `Create`, never in a hand-written `Equals`: equality, ordering, the JSON and the column +are all the wrapped value's, so two values that should be equal must hold the same string. A plain +value object that needs normalising becomes an `IValidatedValue` for that alone. The private +constructor applies no rules, so call it from `Create` only. + +The order of the checks is the order the fault reports: `Blank` before `NoAt`, so a caller that +shows one message shows the first thing to fix. + +## Faults + +A fault names the rule, never the input. The text a value object refused can be a password or an +address, and the fault is what reaches an exception message, a log and a problem-details response +([ASP.NET Core](aspnet-core.md#a-result-as-a-response)). An enum is enough for most value objects. A fault +that must carry something carries the rule's own data: + +```csharp +public abstract record QuantityFault +{ + public sealed record NotPositive : QuantityFault; + + public sealed record AboveLimit(int Limit) : QuantityFault; // the limit, never the value given +} + +public readonly partial record struct Quantity : IValidatedValue +{ + public const int Limit = 1_000; + + public static Result Create(int value) + { + if (value <= 0) return Result.Error(new QuantityFault.NotPositive()); + if (value > Limit) return Result.Error(new QuantityFault.AboveLimit(Limit)); + + return new Quantity(value); + } +} +``` + +```csharp +static string Describe(QuantityFault fault) => fault switch +{ + QuantityFault.NotPositive => "must be positive", + QuantityFault.AboveLimit { Limit: var limit } => $"at most {limit}", + _ => throw new UnreachableException(), +}; + +string explained = Quantity.Create(5_000).Match(quantity => quantity.ToString(), Describe); // "at most 1000" +``` + +`Result.Error(new QuantityFault.AboveLimit(Limit))` alone would infer the derived type, so the base +is named as the type argument. Where the fault is an enum, `Result.Error(EmailFault.NoAt)` infers it. + +## Numbers + +```csharp +public readonly partial record struct Weight : IValue; // any decimal, with MinValue and MaxValue + +public enum PercentageFault { BelowZero, AboveHundred } + +public readonly partial record struct Percentage : IValidatedValue +{ + public static Result Create(int value) + { + if (value < 0) return Result.Error(PercentageFault.BelowZero); + if (value > 100) return Result.Error(PercentageFault.AboveHundred); + + return new Percentage(value); + } + + public override string ToString() => $"{Value} %"; // kept; then no formatting interface is generated, so interpolation reaches it too +} +``` + +```csharp +bool widest = Weight.MaxValue.Value == decimal.MaxValue; // true +string shown = $"{Percentage.FromKnownGood(42)}"; // "42 %" +bool ordered = Percentage.FromKnownGood(5) < Percentage.FromKnownGood(50); // true, the wrapped type's ordering +``` + +A plain value object over a type with its own `MinValue` and `MaxValue` gets both, and implements +`IMinMaxValue`; a validated one never does, since its range is `Create`'s. Every value object +over a comparable type gets `CompareTo` and the operators, ordinal for a string. The generated +`ToString()` is invariant: `Weight.From(1.5m)` prints `1.5` whatever the current culture. + +## Enums, dates and other wrapped types + +```csharp +public readonly partial record struct Weekday : IValue; + +public readonly partial record struct DueOn : IValue; + +public readonly partial record struct Homepage : IValue; +``` + +```csharp +var day = Weekday.Parse("Friday", null); // Friday +var fifth = Weekday.Parse("5", null); // Friday, as Enum.TryParse reads a number +var due = DueOn.Parse("2026-10-13", null); // 2026-10-13 +bool sooner = DueOn.From(new DateOnly(2026, 1, 1)) < due; // true +bool sortable = typeof(IComparable).IsAssignableFrom(typeof(Homepage)); // false, a Uri has no ordering +``` + +The generated members follow what the wrapped type offers: parsing where it parses, formatting where +it formats, comparison where it is comparable. A `Uri` parses and formats but has no ordering; NodaTime's +types parse through their type converters. A null `IFormatProvider` means the invariant culture +throughout, so `Parse(x.ToString(), null)` round-trips wherever the process runs. + +What cannot be wrapped is refused at build time (CMTK1005): a generic type, a tuple included, because +a `List` compares by reference; a nullable; an array; and another value object, since wrapping +what it wraps says the same with one type. + +## Which factory + +| Call | When | On refusal | +|---|---|---| +| `Create(value)` | Input: a request, a form, a file. The caller has to say what to fix | Returns the fault | +| `TryFrom(value)` | Valid or not is the whole question | `None` | +| `FromKnownGood(value)` | A literal in source, or a value the code has just produced | Throws, naming the call site's expression and the fault, never the value: `request.Code`, not what it held. A literal is its own expression, and is named as written (CMTK0007 points at a parameter given to it) | +| `From(value)` | A plain value object: there are no rules | Throws only for a null | +| `Revalidate()` | An instance read back without the rules, a column or a stored document, against today's `Create` | Returns the fault | + +```csharp +Result fromRequest = Email.Create(request.Email); // the fault says which rule refused it +Option maybe = Email.TryFrom(request.Email); // is it valid? +Email support = Email.FromKnownGood("support@example.com"); // a refusal here is a bug at this line +InvoiceNo first = InvoiceNo.From(1); // no rules +Result stillValid = support.Revalidate(); // today's rules, applied again +``` + +A validated value object has no `From`, so nothing that looks harmless throws on input. The explicit +materializer that EF Core reads a column through is the one way in that skips `Create`, and calling +it anywhere else is CMTK0004 ([EF Core](ef-core.md#reading-back-without-validation)). + +## Your own members + +A value object may declare `TryFrom`, `FromKnownGood`, `Revalidate`, `CompareTo(TSelf)` and +`ToString()`; each is kept, and what depends on it is derived from it. Any other member the generators +introduce, written by hand, is CMTK1011, and any instance state beside the wrapped value is CMTK1012. + +```csharp +public readonly partial record struct Priority : IValue +{ + public int CompareTo(Priority other) => other.Value.CompareTo(Value); // 1 sorts before 2; the operators follow +} +``` + +```csharp +var first = new[] { Priority.From(3), Priority.From(1) }.Min(); // 3 +bool urgent = Priority.From(1) > Priority.From(2); // true +``` + +A computed property, a static member and a constant are fine, as `Email.Domain` and `Quantity.Limit` +above. A `ToString()` that masks a secret is in [Logging and secrets](logging-and-secrets.md). + +## Declared inside another type + +```csharp +public sealed partial class Shipment +{ + public readonly partial record struct Id : IValue; // Shipment.Id; its companion class is ShipmentIdExtensions + + public required Id Key { get; init; } +} +``` + +The companion class, which holds `GetValue()` and `ValueOrNull()` for EF Core queries, is named +after the whole nesting chain, so `Shipment.Id` and `Order.Id` in one namespace do not collide. The +OpenAPI document names the component the same way, `ShipmentId`. + +## Generic code over value objects + +The contracts are static abstract, so one method serves every value object of a kind. The generated +types also implement `IEqualityOperators`, `IComparisonOperators` and `IMinMaxValue` where the wrapped +type allows, so they satisfy generic-math constraints. + +```csharp +static Result, TFault> CreateAll(IEnumerable values) + where TSelf : IValidatedValue + where T : notnull + where TFault : notnull => + values.Traverse(TSelf.Create); // the first fault stops it; nothing after it is created +``` + +```csharp +string outcome = CreateAll(["ops@example.com", "nobody"]).Match(list => $"{list.Count} valid", fault => $"refused: {fault}"); // "refused: NoAt" +``` + +## Reading what was generated + +Set `true` in the +declaring project and read `obj/Debug/net10.0/metalama/`: every generated member is there as ordinary +C#. A declaration the generators cannot complete fails the build with CMTK1000 to CMTK1012, listed in +the [Generators README](../src/CodoMetis.TypeKit.Generators/README.md#build-errors). diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs new file mode 100644 index 0000000..31e860d --- /dev/null +++ b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace CodoMetis.TypeKit.Conventions.Tests; + +/// +/// Every relative link in the root README, the package READMEs and the docs/ pages opens a +/// file or directory that exists, and every anchor in one is a heading of the file it points at, derived as +/// GitHub derives it. A link to a renamed page is a 404; a link to a renamed heading opens the top +/// of the page, silently, which is the worse of the two. +/// +public sealed partial class DocumentationLinkTests +{ + private static readonly Lazy> Links = new(() => + [.. ReadmeSamples.Readmes().SelectMany(page => RelativeLinks(page, File.ReadAllText(Path.Combine(Repository.Root, page))))]); + + public static TheoryData Locations => [.. Links.Value.Select(link => link.Location)]; + + [Fact] + public void The_pages_link_each_other() => Links.Value.Count.ShouldBeGreaterThanOrEqualTo(60, string.Join(", ", Links.Value.Select(link => link.Location))); + + [Theory] + [MemberData(nameof(Locations))] + public void A_relative_link_opens_a_file_and_its_anchor_is_a_heading(string location) + { + var link = Links.Value.Single(candidate => candidate.Location == location); + var target = Path.GetFullPath(Path.Combine(Repository.Root, Path.GetDirectoryName(link.Page) ?? "", link.Path)); + + (File.Exists(target) || Directory.Exists(target)).ShouldBeTrue($"{location} links {link.Path}, which does not exist."); + + if (link.Anchor is null) return; + + var headings = Headings(File.ReadAllText(target)); + headings.ShouldContain(link.Anchor, $"{location} links #{link.Anchor}, which is no heading of {link.Path}. The headings are: {string.Join(", ", headings)}."); + } + + /// The positive control: links with and without anchors are found, external and in-page links are left alone, and the slug is GitHub's. + [Fact] + public void Links_and_headings_are_read_as_GitHub_reads_them() + { + const string markdown = + """ + # Title + + See [the plan](design/plan.md#2-phases), [EF Core](ef-core.md), [here](#the-surface) and [GitHub](https://github.com/x). + + ## Option<T> + + ## Result<T, TError> and Result<TError> + + ## The surface + + ### CMTK0010 + """; + + RelativeLinks("docs/page.md", markdown).Select(link => (link.Path, link.Anchor)).ShouldBe([("design/plan.md", "2-phases"), ("ef-core.md", null)]); + Headings(markdown).ShouldBe(["title", "optiont", "resultt-terror-and-resultterror", "the-surface", "cmtk0010"]); + } + + private static IReadOnlyList RelativeLinks(string page, string markdown) => + [ + .. MarkdownLink().Matches(markdown.ReplaceLineEndings("\n")) + .Select(match => (Target: match.Groups["target"].Value, Line: markdown.ReplaceLineEndings("\n")[..match.Index].Count(c => c == '\n') + 1)) + .Where(link => !link.Target.StartsWith("http", StringComparison.Ordinal) && !link.Target.StartsWith('#')) + .Select(link => link.Target.Split('#', 2) is var parts + ? new Link(page, link.Line, parts[0], parts.Length > 1 ? parts[1] : null) + : throw new InvalidOperationException()) + ]; + + /// The headings' anchors as GitHub derives them: entities decoded, lowercased, punctuation dropped, spaces to hyphens. + private static IReadOnlyList Headings(string markdown) => + [ + .. Heading().Matches(markdown.ReplaceLineEndings("\n")) + .Select(match => WebUtility.HtmlDecode(match.Groups["text"].Value).Trim().ToLowerInvariant()) + .Select(text => NotInASlug().Replace(text, "").Replace(' ', '-')) + ]; + + private sealed record Link(string Page, int Line, string Path, string? Anchor) + { + public string Location => $"{Page}:{Line} -> {Path}{(Anchor is null ? "" : $"#{Anchor}")}"; + } + + [GeneratedRegex(@"\]\((?[^)\s]+)\)")] + private static partial Regex MarkdownLink(); + + [GeneratedRegex(@"^#{1,6} (?.+)$", RegexOptions.Multiline)] + private static partial Regex Heading(); + + [GeneratedRegex(@"[^\p{L}\p{N} _-]")] + private static partial Regex NotInASlug(); +} diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs index 1f8f16e..f57ad39 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs @@ -1,9 +1,9 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// -/// Every C# block in the READMEs is compiled code: it equals, after indentation, exactly one sample -/// region in test/CodoMetis.TypeKit.Samples, and every region there is shown in the README its -/// name starts with. A README edit that is not mirrored in the samples fails here, and a sample that +/// Every C# block in the READMEs and the topic pages under docs/ is compiled code: it equals, +/// after indentation, exactly one sample region in test/CodoMetis.TypeKit.Samples, and every +/// region there is shown in the page its name starts with. A README edit that is not mirrored in the samples fails here, and a sample that /// stops compiling fails the build. /// /// @@ -11,7 +11,7 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// A pre-release audit compiled the READMEs' blocks and found a bare fault returned from a method typed /// Result<T, TError>, statements that are not statements (quantity.Value;), and SQL in a /// comment that the query does not produce. The consumer smoke test had found the first once before -/// (docs/plan.md §9). Each was fixed by hand, and nothing kept the next one out. +/// (design/plan.md §9). Each was fixed by hand, and nothing kept the next one out. /// /// /// A block that cannot compile on purpose, showing a compile error, says so above its fence: @@ -35,6 +35,56 @@ public sealed class ReadmeSampleTests public void The_READMEs_are_discovered() => ReadmeSamples.Readmes().Count.ShouldBeGreaterThanOrEqualTo(6, string.Join(", ", ReadmeSamples.Readmes())); + /// The topic pages are found: a docs/ that yielded nothing would compile no page and fail nothing. + [Fact] + public void The_docs_pages_are_discovered() => + ReadmeSamples.DocsPages().Count.ShouldBeGreaterThanOrEqualTo(8, string.Join(", ", ReadmeSamples.DocsPages())); + + [Theory] + [InlineData("README.md", "README")] + [InlineData("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md", "CodoMetis.TypeKit.EntityFrameworkCore")] + [InlineData("docs/ef-core.md", "ef-core")] + public void A_page_s_samples_are_named_after_it(string page, string prefix) => ReadmeSamples.Prefix(page).ShouldBe(prefix); + + /// + /// The root README's "Documentation" section is the table of contents: every topic page is linked + /// from it, and every page it links exists. A page written and never listed cannot be reached from + /// where a reader starts, and a link to a page since renamed opens nothing, silently. + /// + [Fact] + public void The_root_README_lists_every_docs_page_and_links_only_pages_that_exist() + { + var listed = ReadmeSamples.DocumentationLinks(File.ReadAllText(Path.Combine(Repository.Root, ReadmeSamples.RootReadme))); + + listed.Count.ShouldBeGreaterThanOrEqualTo(8, string.Join(", ", listed)); + ReadmeSamples.DocsPages().Except(listed).ShouldBeEmpty("Every page in docs/ is linked from the Documentation section of README.md."); + listed.Where(page => !File.Exists(Path.Combine(Repository.Root, page))).ShouldBeEmpty("The Documentation section of README.md links a page that does not exist."); + } + + /// The positive control for the table of contents: links with and without anchors are found, in order, and other sections are not read. + [Fact] + public void Documentation_links_are_found_in_the_section_alone() + { + const string markdown = + """ + # Title + + ## Documentation + + | Page | Covers | + |---|---| + | [EF Core](docs/ef-core.md) | columns | + | [JSON](docs/json.md#stored-documents) | stored | + + ## Status + + See [the plan](design/plan.md) and [JSON](docs/json.md). + """; + + ReadmeSamples.DocumentationLinks(markdown).ShouldBe(["docs/ef-core.md", "docs/json.md"]); + Should.Throw(() => ReadmeSamples.DocumentationLinks("# Title\n\nno section")).Message.ShouldBe("README.md has no '## Documentation' section."); + } + [Fact] public void The_csharp_blocks_and_the_samples_are_discovered() { @@ -71,7 +121,7 @@ public void A_sample_is_shown_in_the_README_its_name_starts_with(string name) readme.ShouldNotBeNull( $"'{name}' ({sample.Location}) starts with '{sample.ReadmePrefix}', which is no README. " + - $"A sample's name starts with README or a package id: {string.Join(", ", ReadmeSamples.Readmes().Select(ReadmeSamples.Prefix))}."); + $"A sample's name starts with README, a package id or a topic page's name: {string.Join(", ", ReadmeSamples.Readmes().Select(ReadmeSamples.Prefix))}."); var shown = Blocks.Value.Where(block => block.Readme == readme && block.IsCSharp).ToArray(); diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs index 2ca3c08..ab771ab 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs @@ -40,12 +40,55 @@ internal static partial class ReadmeSamples public const string RootReadme = "README.md"; - /// The root README and every shipping package's, discovered from src/. + public const string DocsDirectory = "docs"; + + /// + /// Every page whose C# is compiled: the root README, every shipping package's, discovered from + /// src/, and every topic page in docs/ (). + /// public static IReadOnlyList Readmes() => - [RootReadme, .. Repository.ShippingProjects().Select(project => $"src/{project}/README.md")]; + [RootReadme, .. Repository.ShippingProjects().Select(project => $"src/{project}/README.md"), .. DocsPages()]; + + /// + /// The topic pages, discovered from docs/: every Markdown file there. A page is compiled by + /// being there, so one that is forgotten in the root README's table fails + /// rather than going unread. The plan and the design notes live in + /// design/, whose code is sketches and is not compiled. + /// + public static IReadOnlyList DocsPages() => + [ + .. Directory.EnumerateFiles(Path.Combine(Repository.Root, DocsDirectory), "*.md", SearchOption.TopDirectoryOnly) + .Select(path => $"{DocsDirectory}/{Path.GetFileName(path)}") + .Order(StringComparer.Ordinal) + ]; + + /// + /// The prefix of the samples a page shows: README for the root README, the package id for + /// a package's, and the file name without .md for a topic page (docs/ef-core.md shows + /// ef-core/…). + /// + public static string Prefix(string readme) => + readme == RootReadme ? "README" + : readme.StartsWith("src/", StringComparison.Ordinal) ? readme.Split('/')[1] + : Path.GetFileNameWithoutExtension(readme); + + /// + /// The pages the root README's "Documentation" section links under docs/, in order, each + /// without its anchor: the table of contents a reader starts from. + /// + public static IReadOnlyList DocumentationLinks(string markdown) + { + var section = DocumentationSection().Match(markdown.ReplaceLineEndings("\n")); + if (!section.Success) throw new InvalidOperationException($"{RootReadme} has no '## Documentation' section."); + + return [.. DocsLink().Matches(section.Groups["body"].Value).Select(link => link.Groups["page"].Value)]; + } + + [GeneratedRegex(@"^## Documentation\n(?.*?)(?=^## |\z)", RegexOptions.Multiline | RegexOptions.Singleline)] + private static partial Regex DocumentationSection(); - /// The prefix of the samples a README shows: README, or the package id. - public static string Prefix(string readme) => readme == RootReadme ? "README" : readme.Split('/')[1]; + [GeneratedRegex(@"\]\((?docs/[^)#\s]+)(?:#[^)\s]*)?\)")] + private static partial Regex DocsLink(); public static IReadOnlyList CodeBlocks() => [.. Readmes().SelectMany(readme => CodeBlocks(readme, File.ReadAllText(Path.Combine(Repository.Root, readme))))]; diff --git a/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs new file mode 100644 index 0000000..643d401 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs @@ -0,0 +1,248 @@ +// sample: aspnet-core/usings +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.AspNetCore.Http.HttpResults; +// end sample +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Docs.Web; + +// sample: aspnet-core/types +public readonly partial record struct CustomerId : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); +} + +public enum PageFault { NotPositive, TooLarge } + +public readonly partial record struct PageSize : IValidatedValue +{ + public static Result Create(int value) => + value <= 0 ? Result.Error(PageFault.NotPositive) : value > 100 ? Result.Error(PageFault.TooLarge) : new PageSize(value); +} + +public enum CustomerFault { NotFound, EmailTaken } + +public sealed record RegisterCustomer(Email Email, string Name); + +public sealed record CustomerDto(CustomerId Id, Email Email, string Name); +// end sample + +// sample: aspnet-core/service +public interface ICustomers +{ + Result Find(CustomerId id); + + Result Register(RegisterCustomer request); + + IReadOnlyList List(Email? email, PageSize? page); +} +// end sample + +// sample: aspnet-core/mvc +[ApiController] +[Route("mvc/customers")] +public sealed class CustomersController(ICustomers customers) : ControllerBase +{ + [HttpGet("{id}")] + public Results, FaultProblem> Get([FromRoute] CustomerId id) => customers.Find(id).ToHttpResult(); // the generated TypeConverter binds it + + [HttpPost] + public Results, FaultProblem> Post(RegisterCustomer body) => customers.Register(body).ToHttpResult(); +} +// end sample + +public static class Host +{ + public static void Configure(WebApplicationBuilder builder) + { + // sample: aspnet-core/setup + builder.Services.AddOpenApi(options => options.AddTypeKit()); // the document: every value object as the schema of what it wraps + + builder.Services.AddFaultResponses(fault => fault switch // a Result as a response: the status per fault + { + CustomerFault.NotFound => StatusCodes.Status404NotFound, + CustomerFault.EmailTaken => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + // end sample + } + + public static void Map(WebApplication app, ICustomers customers) + { +#pragma warning disable ASP0020 // the value objects are declared in this project, as the page says + // sample: aspnet-core/minimal-api + app.MapGet("/customers/{id}", (CustomerId id) => customers.Find(id).ToHttpResult()); // a route parameter, through the generated TryParse + app.MapGet("/customers", (Email? email, PageSize? page) => customers.List(email, page)); // query parameters, optional + app.MapPost("/customers", (RegisterCustomer body) => customers.Register(body).ToHttpResult()); // a body, through the generated JSON converter + // end sample +#pragma warning restore ASP0020 + } + + public static bool Outcome(ICustomers customers) + { + // sample: aspnet-core/handler-outcome + var response = customers.Find(CustomerId.New()).ToHttpResult(); + + bool notFound = response.Result is FaultProblem { Fault: CustomerFault.NotFound }; // true + // end sample + + return notFound; + } + + public static void Customize(WebApplicationBuilder builder) + { + // sample: aspnet-core/customize + builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["service"] = "customers"); + // end sample + } +} + +/// One registered customer; a second registration of the same address is a conflict. +public sealed class InMemoryCustomers : ICustomers +{ + public CustomerDto Ann { get; } = new(CustomerId.New(), Email.FromKnownGood("ann@example.com"), "Ann"); + + public Result Find(CustomerId id) => id == Ann.Id ? Ann : Result.Error(CustomerFault.NotFound); + + public Result Register(RegisterCustomer request) => + request.Email == Ann.Email ? Result.Error(CustomerFault.EmailTaken) : new CustomerDto(CustomerId.New(), request.Email, request.Name); + + public IReadOnlyList List(Email? email, PageSize? page) => email is null || email == Ann.Email ? [Ann] : []; +} + +public sealed partial class AspNetCoreTests +{ + private const string Readme = "docs/aspnet-core.md"; + + private static async Task<(WebApplication App, InMemoryCustomers Customers)> StartAsync(bool customize = false) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + var customers = new InMemoryCustomers(); + builder.Services.AddSingleton(customers); + builder.Services.AddControllers().AddApplicationPart(typeof(CustomersController).Assembly); + Host.Configure(builder); + if (customize) Host.Customize(builder); + + var app = builder.Build(); + app.MapOpenApi(); + Host.Map(app, customers); + app.MapControllers(); + await app.StartAsync(TestContext.Current.CancellationToken); + + return (app, customers); + } + + /// The table under "What a refused value answers", row by row, against the running host. + [Fact] + public async Task A_refused_value_answers_as_the_table_says() + { + var (app, customers) = await StartAsync(); + await using var _ = app; + var client = app.GetTestClient(); + var token = TestContext.Current.CancellationToken; + + (await client.GetAsync($"/customers/{customers.Ann.Id}", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + (await client.GetAsync("/customers/not-an-id", token)).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await client.GetAsync("/customers?page=500", token)).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await client.GetAsync("/customers?page=50&email=ann@example.com", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + + using var refused = await client.PostAsync("/customers", Body("""{"email":"nobody","name":"Bo"}"""), token); + refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await refused.Content.ReadAsStringAsync(token)).ShouldBeEmpty("a minimal API logs the JsonException and answers an empty 400"); + + using var missing = await client.PostAsync("/customers", Body("""{"name":"Bo"}"""), token); + missing.StatusCode.ShouldBe(HttpStatusCode.OK, "without RespectRequiredConstructorParameters the handler sees a default Email"); + + using var mvc = await client.PostAsync("/mvc/customers", Body("""{"email":"nobody","name":"Bo"}"""), token); + mvc.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problem = await mvc.Content.ReadAsStringAsync(token); + problem.ShouldContain("NoAt"); + problem.ShouldContain("Email"); + problem.ShouldNotContain("nobody"); + (await client.GetAsync($"/mvc/customers/{customers.Ann.Id}", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + } + + /// The problem the page shows, read from its first JSON block, against what the host answers for an unknown id. + [Fact] + public async Task A_fault_is_answered_with_the_problem_the_page_shows() + { + var (app, _) = await StartAsync(); + await using var _ = app; + var token = TestContext.Current.CancellationToken; + + using var response = await app.GetTestClient().GetAsync($"/customers/{Guid.Empty}", token); + var answered = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject(); + var shown = JsonBlocks()[0].AsObject(); + + ((int)response.StatusCode).ShouldBe(shown["status"]!.GetValue()); + answered.Select(member => member.Key).Order().ShouldBe(shown.Select(member => member.Key).Order()); + foreach (var (name, value) in shown.Where(member => member.Key != "traceId")) + JsonNode.DeepEquals(answered[name], value).ShouldBeTrue($"{name}: {answered[name]?.ToJsonString()} where the page shows {value?.ToJsonString()}"); + + using var conflict = await app.GetTestClient().PostAsync("/customers", Body("""{"email":"ann@example.com","name":"Ann"}"""), token); + conflict.StatusCode.ShouldBe(HttpStatusCode.Conflict); + } + + [Fact] + public void A_handler_s_outcome_is_read_without_a_host() => + Host.Outcome(new InMemoryCustomers()).ShouldBeShownIn(Readme, found => found ? "true" : "false", "response.Result is FaultProblem { Fault: CustomerFault.NotFound }"); + + [Fact] + public async Task The_host_s_problem_details_customisation_reaches_a_fault() + { + var (app, _) = await StartAsync(customize: true); + await using var _ = app; + var token = TestContext.Current.CancellationToken; + + using var response = await app.GetTestClient().GetAsync($"/customers/{Guid.Empty}", token); + var answered = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject(); + + answered["service"]!.GetValue().ShouldBe("customers"); + answered["fault"]!.GetValue().ShouldBe("NotFound"); + } + + /// The operation the page shows, read from its second JSON block: every keyword it shows, the document has. + [Fact] + public async Task The_document_says_what_the_page_shows() + { + var (app, _) = await StartAsync(); + await using var _ = app; + + var json = await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + var document = JsonNode.Parse(json)!; + var shown = JsonBlocks()[1].AsObject(); + + foreach (var (path, item) in shown) document["paths"]![path].ShouldContainSchema(item!, path); + document.Component("CustomerId").ShouldContainSchema(JsonNode.Parse("""{"type":"string","format":"uuid"}""")!, "CustomerId"); + var page = document["paths"]!["/customers"]!["get"]!["parameters"]!.AsArray().Single(parameter => parameter!["name"]!.GetValue() == "page"); + page!["schema"]!["type"]!.ToJsonString().ShouldContain("integer", Case.Sensitive, "a query parameter is described as a parameter of the wrapped type, inline"); + } + + private static List JsonBlocks() => + [.. JsonBlock().Matches(SampleOutputs.Readme(Readme)).Select(block => JsonNode.Parse(block.Groups["body"].Value.TrimStart().StartsWith('{') ? block.Groups["body"].Value : $"{{{block.Groups["body"].Value}}}")!)]; + + private static StringContent Body(string json) => new(json, Encoding.UTF8, "application/json"); + + [GeneratedRegex(@"^```json\n(?.*?)\n```", RegexOptions.Multiline | RegexOptions.Singleline)] + private static partial Regex JsonBlock(); +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/EfCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/EfCoreSamples.cs new file mode 100644 index 0000000..17f6e79 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/EfCoreSamples.cs @@ -0,0 +1,201 @@ +// sample: ef-core/usings +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.EntityFrameworkCore; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +// end sample +using Microsoft.EntityFrameworkCore.Metadata; + +namespace ReadmeSamples.Docs.EfCore; + +// sample: ef-core/types +public readonly partial record struct InvoiceNo : IValue; + +public readonly partial record struct CustomerId : IValue; + +public readonly partial record struct Amount : IValue; + +public readonly partial record struct Currency : IValue; + +public readonly partial record struct IssuedOn : IValue; + +public readonly partial record struct Discount : IValue; + +public enum ReferenceFault { Blank, NotUpperCase } + +public readonly partial record struct Reference : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(ReferenceFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(ReferenceFault.NotUpperCase); // a rule added after rows were written + + return new Reference(value); + } +} +// end sample + +// sample: ef-core/model +public class Invoice +{ +#pragma warning disable CMTK0006 // the database assigns the key on insert + public InvoiceNo No { get; set; } // key: bigint, generated by the database +#pragma warning restore CMTK0006 + public required CustomerId Customer { get; set; } // uuid + public required Reference Reference { get; set; } // text, applies Create on input and is read back without it + public required Amount Total { get; set; } // numeric(18,2), from ConfigureConventions + public required Currency Currency { get; set; } // character varying(3), from ConfigureConventions + public required IssuedOn IssuedOn { get; set; } // date + public Discount? Discount { get; set; } // integer, nullable +} + +public sealed class BillingDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Invoices => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder builder) + { + builder.Properties().HavePrecision(18, 2); // by the value object's type: EF applies a convention by CLR type, so Properties() would miss it + builder.Properties().HaveMaxLength(3); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(invoice => invoice.No); + modelBuilder.Entity().HasData(new Invoice // seed data takes value objects as it takes primitives + { + No = InvoiceNo.From(1), + Customer = CustomerId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), + Reference = Reference.FromKnownGood("INV-1"), + Total = Amount.From(10m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 1, 1)), + }); + } +} +// end sample + +public static class Usage +{ + public static async Task<(long Assigned, string Verdict, string Stored, Option DiscountRead)> WalkthroughAsync() + { + // sample: ef-core/keys + await using var connection = new SqliteConnection("Data Source=:memory:"); // one connection keeps the in-memory database alive + await connection.OpenAsync(); + await using var db = new BillingDb(new DbContextOptionsBuilder().UseSqlite(connection).UseTypeKit().Options); + await db.Database.EnsureCreatedAsync(); + + var invoice = new Invoice + { + Customer = CustomerId.New(), + Reference = Reference.FromKnownGood("INV-2"), + Total = Amount.From(250m), + Currency = Currency.From("EUR"), + IssuedOn = IssuedOn.From(new DateOnly(2026, 10, 13)), + }; + db.Invoices.Add(invoice); + await db.SaveChangesAsync(); + + long assigned = invoice.No.Value; // 2, assigned by the database; the seeded invoice is 1 + // end sample + + // sample: ef-core/reading-back + await db.Database.ExecuteSqlAsync($"UPDATE Invoices SET Reference = 'inv-2' WHERE No = 2"); // a row from before the upper-case rule + db.ChangeTracker.Clear(); + + var read = await db.Invoices.SingleAsync(i => i.No == InvoiceNo.From(2)); // maps, without Create + string verdict = read.Reference.Revalidate().Match(_ => "valid", fault => $"refused: {fault}"); // "refused: NotUpperCase" + string stored = read.Reference.Value; // "inv-2", as the row holds it + // end sample + + // sample: ef-core/option + Option discount = read.Discount.ToOption(); // None + // end sample + + return (assigned, verdict, stored, discount); + } + + public static void Queries(BillingDb db, Currency eur, List customers) + { + // sample: ef-core/queries + db.Invoices.Where(i => i.Total.Value >= 100m && i.Currency == eur); // WHERE i."Total" >= 100.0 AND i."Currency" = @eur + db.Invoices.Where(i => i.Reference.Value.EndsWith("-1")); // WHERE i."Reference" LIKE '%-1' + db.Invoices.Where(i => i.IssuedOn.Value.Year == 2026); // WHERE date_part('year', i."IssuedOn")::int = 2026 + db.Invoices.Where(i => i.Discount.ValueOrNull() == null); // WHERE i."Discount" IS NULL + db.Invoices.Where(i => customers.Contains(i.Customer)); // WHERE i."Customer" = ANY (@customers) + db.Invoices.OrderByDescending(i => i.IssuedOn).ThenBy(i => i.No); + db.Invoices.Select(i => new { i.No, Total = i.Total.Value }); // the wrapped value projected + db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g.Sum(i => i.Total.Value) }); // an aggregate over the column + // end sample + } +} + +public sealed class EfCoreTests +{ + private const string Readme = "docs/ef-core.md"; + + /// Each property's comment in the model sample names what the model maps it to on PostgreSQL. + [Fact] + public void The_columns_are_what_the_model_s_comments_say() + { + using var db = new BillingDb(SampleOutputs.NpgsqlWithoutServer()); + var invoice = db.Model.FindEntityType(typeof(Invoice))!; + var comments = SampleOutputs.PropertyComments(Readme, "public class Invoice"); + IProperty Property(string name) => invoice.FindProperty(name)!; + + comments.Keys.ShouldBe(invoice.GetProperties().Select(property => property.Name), ignoreOrder: true); + + invoice.FindPrimaryKey()!.Properties.ShouldBe([Property(nameof(Invoice.No))]); + Property(nameof(Invoice.No)).ValueGenerated.ShouldBe(ValueGenerated.OnAdd); + comments[nameof(Invoice.No)].ShouldBe($"key: {Property(nameof(Invoice.No)).GetColumnType()}, generated by the database"); + comments[nameof(Invoice.Customer)].ShouldBe(Property(nameof(Invoice.Customer)).GetColumnType()); + comments[nameof(Invoice.Reference)].ShouldStartWith($"{Property(nameof(Invoice.Reference)).GetColumnType()}, "); + comments[nameof(Invoice.Total)].ShouldStartWith($"{Property(nameof(Invoice.Total)).GetColumnType()}, "); + comments[nameof(Invoice.Currency)].ShouldStartWith($"{Property(nameof(Invoice.Currency)).GetColumnType()}, "); + comments[nameof(Invoice.IssuedOn)].ShouldBe(Property(nameof(Invoice.IssuedOn)).GetColumnType()); + Property(nameof(Invoice.Discount)).IsNullable.ShouldBeTrue(); + comments[nameof(Invoice.Discount)].ShouldBe($"{Property(nameof(Invoice.Discount)).GetColumnType()}, nullable"); + } + + [Fact] + public void The_queries_are_the_SQL_the_comments_say() + { + using var db = new BillingDb(SampleOutputs.NpgsqlWithoutServer()); + var eur = Currency.From("EUR"); + List customers = [CustomerId.New()]; + + db.Invoices.Where(i => i.Total.Value >= 100m && i.Currency == eur).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => i.Reference.Value.EndsWith("-1")).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => i.IssuedOn.Value.Year == 2026).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => i.Discount.ValueOrNull() == null).ShouldBeTheSqlShownIn(Readme); + db.Invoices.Where(i => customers.Contains(i.Customer)).ShouldBeTheSqlShownIn(Readme); + db.Invoices.OrderByDescending(i => i.IssuedOn).ThenBy(i => i.No).ToQueryString().ShouldContain("ORDER BY i.\"IssuedOn\" DESC, i.\"No\""); + db.Invoices.Select(i => new { i.No, Total = i.Total.Value }).ToQueryString().ShouldContain("SELECT i.\"No\", i.\"Total\""); + db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g.Sum(i => i.Total.Value) }).ToQueryString().ShouldContain("sum(i.\"Total\")"); + Usage.Queries(db, eur, customers); + } + + [Fact] + public async Task The_walkthrough_answers_what_the_comments_say() + { + var (assigned, verdict, stored, discount) = await Usage.WalkthroughAsync(); + + assigned.ShouldBeShownIn(Readme, value => value.ToString(), "invoice.No.Value"); + verdict.ShouldBeShownIn(Readme, text => $"\"{text}\"", "read.Reference.Revalidate().Match(_ => \"valid\", fault => $\"refused: {fault}\")"); + stored.ShouldBeShownIn(Readme, text => $"\"{text}\"", "read.Reference.Value"); + discount.ShouldBeShownIn(Readme, option => option.IsNone() ? "None" : "Some", "read.Discount.ToOption()"); + } + + [Fact] + public async Task The_seed_is_written_with_the_schema() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var db = new BillingDb(new DbContextOptionsBuilder().UseSqlite(connection).UseTypeKit().Options); + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + + var seeded = await db.Invoices.SingleAsync(TestContext.Current.CancellationToken); + (seeded.No, seeded.Reference, seeded.Total, seeded.Currency).ShouldBe((InvoiceNo.From(1), Reference.FromKnownGood("INV-1"), Amount.From(10m), Currency.From("EUR"))); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/JsonSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/JsonSamples.cs new file mode 100644 index 0000000..d5d93c7 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/JsonSamples.cs @@ -0,0 +1,235 @@ +// sample: json/usings +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +// end sample +using System.Text.Json.Serialization.Metadata; + +namespace ReadmeSamples.Docs.Json; + +// sample: json/types +public readonly partial record struct Quantity : IValue; + +public readonly partial record struct Weekday : IValue; + +public enum EmailFault { NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record Shipment(Quantity Boxes, Weekday Day, Dictionary Plan); + +public sealed record Primitives(int Boxes, DayOfWeek Day, Dictionary Plan); +// end sample + +// sample: json/parcel +public sealed record Parcel(Quantity Boxes, Email? Contact); +// end sample + +// sample: json/product-code +public enum CodeFault { NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) => + value == value.ToUpperInvariant() ? new ProductCode(value) : Result.Error(CodeFault.NotUpperCase); // the rule added after "abc" was stored +} +// end sample + +// sample: json/profile +public sealed record Profile(Option Nickname); +// end sample + +// sample: json/option-converter +public sealed class OptionAsNullableConverter : JsonConverter> where T : notnull +{ + public override Option Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null + ? Option.None() + : Option.Some(JsonSerializer.Deserialize(ref reader, (JsonTypeInfo)options.GetTypeInfo(typeof(T)))!); // the typed overload: no reflection, so Native AOT keeps it + + public override void Write(Utf8JsonWriter writer, Option value, JsonSerializerOptions options) + { + if (value.TryGetValue(out var content)) JsonSerializer.Serialize(writer, content, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); + else writer.WriteNullValue(); + } +} +// end sample + +// sample: json/contexts +[JsonSerializable(typeof(Shipment))] +internal partial class ShipmentJson : JsonSerializerContext; + +[JsonSerializable(typeof(Shipment))] +[JsonSerializable(typeof(DayOfWeek))] // listed, so the string enum converter reaches what Weekday wraps +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] +internal partial class NamedShipmentJson : JsonSerializerContext; +// end sample + +public static class Usage +{ + public static (string Json, bool Parity, Shipment Quoted) Parity() + { + // sample: json/parity + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + var shipment = new Shipment(Quantity.From(3), Weekday.From(DayOfWeek.Friday), new() { [Weekday.From(DayOfWeek.Friday)] = Quantity.From(3) }); + var primitives = new Primitives(3, DayOfWeek.Friday, new() { [DayOfWeek.Friday] = 3 }); + + string json = JsonSerializer.Serialize(shipment, options); // {"boxes":3,"day":"Friday","plan":{"Friday":3}} + bool parity = json == JsonSerializer.Serialize(primitives, options); // true, the same bytes as the primitives + Shipment quoted = JsonSerializer.Deserialize("""{"boxes":"3","day":"Friday","plan":{}}""", options)!; // "3" read as 3: the web defaults allow a quoted number + // end sample + + return (json, parity, quoted); + } + + public static void Refusals() + { + // sample: json/refusals + JsonSerializer.Deserialize("\"nobody\""); // JsonException naming Email and NoAt, never the text + JsonSerializer.Deserialize("\"many\""); // JsonException naming Quantity and Int32, with no inner exception that quotes it + JsonSerializer.Deserialize("null"); // JsonException naming Quantity and null, refused for a struct + // end sample + } + + public static int NullAndMissing() + { + // sample: json/null-and-missing + var parcel = JsonSerializer.Deserialize("""{"Contact":null}""")!; + int boxes = parcel.Boxes.Value; // 0, a missing property calls no converter and stays default + + var strict = new JsonSerializerOptions { RespectRequiredConstructorParameters = true }; + JsonSerializer.Deserialize("""{"Contact":null}""", strict); // JsonException naming Parcel and Boxes + // end sample + + return boxes; + } + + public static string Stored() + { + // sample: json/stored + var store = new JsonSerializerOptions { Converters = { new StoredJsonConverterFactory() } }; + + ProductCode stored = JsonSerializer.Deserialize("\"abc\"", store); // read without the rules + string state = stored.Revalidate().Match(_ => "still valid", fault => $"now refused: {fault}"); // "now refused: NotUpperCase" + JsonSerializer.Deserialize("\"abc\""); // JsonException naming ProductCode and NotUpperCase, the same document as input + // end sample + + return state; + } + + public static void Refused() + { + // sample: json/option-refused + JsonSerializer.Serialize(new Profile(Option.Some("Annie"))); // NotSupportedException naming Option and nullable, the alternative + // end sample + } + + public static (string Written, string Absent, Profile Back) Wire() + { + // sample: json/option-wire + var wire = new JsonSerializerOptions { Converters = { new OptionAsNullableConverter() } }; + + string written = JsonSerializer.Serialize(new Profile(Option.Some("Annie")), wire); // {"Nickname":"Annie"} + string absent = JsonSerializer.Serialize(new Profile(Option.None()), wire); // {"Nickname":null} + Profile back = JsonSerializer.Deserialize("""{"Nickname":null}""", wire)!; // None + // end sample + + return (written, absent, back); + } + + public static (string Numbered, string Named) Contexts(Shipment shipment) + { + // sample: json/contexts-use + string numbered = JsonSerializer.Serialize(shipment, ShipmentJson.Default.Shipment); // {"Boxes":3,"Day":5,"Plan":{"Friday":3}} + string named = JsonSerializer.Serialize(shipment, NamedShipmentJson.Default.Shipment); // {"Boxes":3,"Day":"Friday","Plan":{"Friday":3}} + // end sample + + return (numbered, named); + } +} + +public sealed class JsonTests +{ + private const string Readme = "docs/json.md"; + + private static string Json(string json) => json; + + [Fact] + public void The_JSON_is_the_primitives_JSON_as_the_comments_say() + { + var (json, parity, quoted) = Usage.Parity(); + + json.ShouldBeShownIn(Readme, Json, "JsonSerializer.Serialize(shipment, options)"); + parity.ShouldBeShownIn(Readme, p => p ? "true" : "false", "json == JsonSerializer.Serialize(primitives, options)"); + quoted.Boxes.ShouldBe(Quantity.From(3)); + quoted.Day.ShouldBe(Weekday.From(DayOfWeek.Friday)); + } + + [Fact] + public void Each_refusal_is_the_exception_the_comment_says() + { + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"nobody\""), Readme, input: "nobody"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"many\""), Readme, input: "many"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("null"), Readme, input: "\"null\""); + Should.Throw(Usage.Refusals); + } + + [Fact] + public void A_missing_property_stays_default_until_the_options_say_otherwise() + { + var parcel = JsonSerializer.Deserialize("""{"Contact":null}""")!; + var strict = new JsonSerializerOptions { RespectRequiredConstructorParameters = true }; + + parcel.Boxes.Value.ShouldBeShownIn(Readme, boxes => boxes.ToString()); + parcel.Contact.ShouldBeNull(); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("""{"Contact":null}""", strict), Readme, input: "Annie"); + Should.Throw(() => Usage.NullAndMissing()); + } + + [Fact] + public void A_stored_document_reads_without_the_rules_as_the_comments_say() + { + var store = new JsonSerializerOptions { Converters = { new StoredJsonConverterFactory() } }; + var stored = JsonSerializer.Deserialize("\"abc\"", store); + + stored.Value.ShouldBe("abc"); + stored.Revalidate().Match(_ => "still valid", fault => $"now refused: {fault}").ShouldBeShownIn(Readme, state => $"\"{state}\""); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"abc\""), Readme, input: "abc"); + Should.Throw(() => Usage.Stored()); + } + + [Fact] + public void An_Option_is_refused_unless_a_converter_is_registered() + { + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Serialize(new Profile(Option.Some("Annie"))), Readme, input: "Annie"); + Should.Throw(Usage.Refused); + + var (written, absent, back) = Usage.Wire(); + var wire = new JsonSerializerOptions { Converters = { new OptionAsNullableConverter() } }; + + JsonSerializer.Serialize(new Profile(Option.Some("Annie")), wire).ShouldBeShownIn(Readme, Json); + JsonSerializer.Serialize(new Profile(Option.None()), wire).ShouldBeShownIn(Readme, Json); + JsonSerializer.Deserialize("""{"Nickname":null}""", wire)!.ShouldBeShownIn(Readme, profile => profile.Nickname.IsNone() ? "None" : "Some", "JsonSerializer.Deserialize(\"\"\"{\"Nickname\":null}\"\"\", wire)!"); + (written, absent, back.Nickname.IsNone()).ShouldBe(("""{"Nickname":"Annie"}""", """{"Nickname":null}""", true)); + JsonSerializer.Deserialize("""{"Nickname":"Bo"}""", wire)!.Nickname.ShouldBe(Option.Some("Bo")); + } + + [Fact] + public void A_context_lists_the_value_objects_and_the_enum_decides_the_names() + { + var shipment = new Shipment(Quantity.From(3), Weekday.From(DayOfWeek.Friday), new() { [Weekday.From(DayOfWeek.Friday)] = Quantity.From(3) }); + + var (numbered, named) = Usage.Contexts(shipment); + + numbered.ShouldBeShownIn(Readme, Json, "JsonSerializer.Serialize(shipment, ShipmentJson.Default.Shipment)"); + named.ShouldBeShownIn(Readme, Json, "JsonSerializer.Serialize(shipment, NamedShipmentJson.Default.Shipment)"); + var back = JsonSerializer.Deserialize(named, NamedShipmentJson.Default.Shipment)!; + (back.Boxes, back.Day).ShouldBe((shipment.Boxes, shipment.Day)); + back.Plan.ShouldBe(shipment.Plan); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/LoggingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/LoggingSamples.cs new file mode 100644 index 0000000..30fd15f --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/LoggingSamples.cs @@ -0,0 +1,180 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +// sample: logging-and-secrets/serilog-using +using Serilog; +// end sample +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting.Display; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace ReadmeSamples.Docs.Logging; + +// sample: logging-and-secrets/token +public readonly partial record struct AccessToken : IValue +{ + public override string ToString() => "AccessToken(***)"; // kept; no formatting interface is generated, so every string path reaches this +} + +public sealed record Session(AccessToken Token, string Name); +// end sample + +// sample: logging-and-secrets/pin +public enum PinFault { TooShort } + +public readonly partial record struct Pin : IValidatedValue +{ + public static Result Create(string value) => + value.Length >= 4 ? new Pin(value) : Result.Error(PinFault.TooShort); +} +// end sample + +/// A Serilog sink that keeps every message rendered as the console sink would ({Message:lj}): what the Serilog sample reads back. +public sealed class ListLogSink : ILogEventSink +{ + private readonly MessageTemplateTextFormatter formatter = new("{Message:lj}", null); + + public List Messages { get; } = []; + + public void Emit(LogEvent logEvent) + { + var rendered = new StringWriter(); + formatter.Format(logEvent, rendered); + Messages.Add(rendered.ToString()); + } +} + +/// A logger provider that keeps every formatted message: what the Microsoft.Extensions.Logging sample reads back. +public sealed class ListSink : ILoggerProvider, ILogger +{ + public List Messages { get; } = []; + + public ILogger CreateLogger(string categoryName) => this; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Messages.Add(formatter(state, exception)); + + public void Dispose() + { + } +} + +public static class Usage +{ + public static (string Interpolated, string Converted, string Record, string Json) Printing() + { + // sample: logging-and-secrets/printing + var token = AccessToken.From("tok-123"); + + string interpolated = $"token {token}"; // "token AccessToken(***)" + string converted = Convert.ToString(token, CultureInfo.InvariantCulture)!; // "AccessToken(***)" + string record = new Session(token, "Ann").ToString(); // Session { Token = AccessToken(***), Name = Ann } + string json = JsonSerializer.Serialize(token); // "tok-123", a conversion, not display + // end sample + + return (interpolated, converted, record, json); + } + + public static string Serilog(AccessToken token) + { + // sample: logging-and-secrets/serilog + var events = new ListLogSink(); // an ILogEventSink of your own that keeps the rendered messages + using var logger = new LoggerConfiguration() + .Destructure.WithTypeKit() + .WriteTo.Sink(events) + .CreateLogger(); + + logger.Information("Opened {@Session}", new Session(token, "Ann")); + string logged = events.Messages.Single(); // Opened {"Token":"AccessToken(***)","Name":"Ann","$type":"Session"} + // end sample + + return logged; + } + + public static string Mel(AccessToken token, ListSink sink) + { + // sample: logging-and-secrets/mel + using var factory = LoggerFactory.Create(logging => logging.AddProvider(sink)); // sink: a provider of your own that keeps the formatted messages + ILogger log = factory.CreateLogger("sessions"); + + log.LogInformation("Opened for {Token}", token); + string message = sink.Messages.Single(); // "Opened for AccessToken(***)" + // end sample + + return message; + } + + public static string Refusals(AccessToken token) + { + // sample: logging-and-secrets/refusals + Pin.Parse("12", null); // FormatException naming Pin and TooShort, never the digits + JsonSerializer.Deserialize("\"12\""); // JsonException naming Pin and TooShort + + var typed = "12"; + Pin.FromKnownGood(typed); // InvalidOperationException naming typed and TooShort, the expression and not what it held + + string printed = Option.Some(token).ToString(); // nothing of the token: an option prints no content, and neither does a result + // end sample + + return printed; + } +} + +public sealed class LoggingTests +{ + private const string Readme = "docs/logging-and-secrets.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + [Fact] + public void Every_string_path_prints_the_mask_and_JSON_carries_the_value() + { + var (interpolated, converted, record, json) = Usage.Printing(); + var token = AccessToken.From("tok-123"); + + $"token {token}".ShouldBeShownIn(Readme, Quoted); + Convert.ToString(token, CultureInfo.InvariantCulture)!.ShouldBeShownIn(Readme, Quoted, "Convert.ToString(token, CultureInfo.InvariantCulture)!"); + new Session(token, "Ann").ToString().ShouldBe(SampleOutputs.Shown(Readme, "new Session(token, \"Ann\").ToString()")); + JsonSerializer.Serialize(token).ShouldBeShownIn(Readme, text => text); + (interpolated, converted, record, json).ShouldBe(("token AccessToken(***)", "AccessToken(***)", "Session { Token = AccessToken(***), Name = Ann }", "\"tok-123\"")); + } + + [Fact] + public void Serilog_logs_the_mask_where_it_destructures() + { + var logged = Usage.Serilog(AccessToken.From("tok-123")); + + logged.ShouldBe(SampleOutputs.Shown(Readme, "events.Messages.Single()")); + logged.ShouldNotContain("tok-123"); + } + + [Fact] + public void Microsoft_Extensions_Logging_prints_the_mask() + { + var message = Usage.Mel(AccessToken.From("tok-123"), new ListSink()); + + message.ShouldBeShownIn(Readme, Quoted, "sink.Messages.Single()"); + message.ShouldNotContain("tok-123"); + } + + [Fact] + public void No_refusal_quotes_the_input() + { + var typed = "12"; + + SampleOutputs.ShouldRefuseAsShownIn(() => Pin.Parse("12", null), Readme, input: "12"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("\"12\""), Readme, input: "12"); + SampleOutputs.ShouldRefuseAsShownIn(() => Pin.FromKnownGood(typed), Readme, input: "12"); + Should.Throw(() => Usage.Refusals(AccessToken.From("tok-123"))); + + Option.Some(AccessToken.From("tok-123")).ToString().ShouldNotContain("tok-123"); + Result.Success(AccessToken.From("tok-123")).ToString().ShouldNotContain("tok-123"); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/NativeAotSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/NativeAotSamples.cs new file mode 100644 index 0000000..5c3e67c --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/NativeAotSamples.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.CompilerServices; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Extensions.Configuration; + +namespace ReadmeSamples.Docs.Aot; + +// sample: native-aot/json-context +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct Quantity : IValue; + +public sealed record OrderLine(OrderId Order, Quantity Quantity); + +[JsonSerializable(typeof(OrderLine))] // the shape; neither Guid nor int is needed for the JSON +internal partial class OrdersJson : JsonSerializerContext; +// end sample + +// sample: native-aot/config-type +public enum HostFault { Blank } + +public readonly partial record struct SmtpHost : IValidatedValue +{ + public static Result Create(string value) => + string.IsNullOrWhiteSpace(value) ? Result.Error(HostFault.Blank) : new SmtpHost(value.Trim()); +} +// end sample + +public static class Usage +{ + public static (string Json, bool RoundTrip) Context() + { + // sample: native-aot/json-use + string json = JsonSerializer.Serialize(new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)), OrdersJson.Default.OrderLine); // {"Order":"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee","Quantity":3} + OrderLine back = JsonSerializer.Deserialize(json, OrdersJson.Default.OrderLine)!; + bool roundTrip = back == new OrderLine(OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")), Quantity.From(3)); // true + // end sample + + return (json, roundTrip); + } + + public static string Configuration() + { + // sample: native-aot/config-use + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Smtp:Host"] = " mail.example.com " }).Build(); + + Result host = SmtpHost.Create(configuration["Smtp:Host"] ?? ""); // the wrapped type bound, then Create + string bound = host.Match(h => h.Value, fault => fault.ToString()); // "mail.example.com" + // end sample + + return bound; + } + + // sample: native-aot/describe + static string Describe(Type type) => + type.GetCustomAttribute() is { } valueObject + ? $"{valueObject.ValueObjectType.Name} wraps {valueObject.WrappedType.Name}" + : $"{type.Name} is no value object"; + // end sample + + public static (string Described, string Plain) Discovery() + { + // sample: native-aot/describe-use + string described = Describe(typeof(OrderId)); // "OrderId wraps Guid" + string plain = Describe(typeof(Guid)); // "Guid is no value object" + // end sample + + return (described, plain); + } +} + +public sealed class NativeAotTests +{ + private const string Readme = "docs/native-aot.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + [Fact] + public void A_context_that_lists_the_shape_alone_writes_the_value_objects() + { + var (json, roundTrip) = Usage.Context(); + + json.ShouldBeShownIn(Readme, text => text, "JsonSerializer.Serialize(new OrderLine(OrderId.From(Guid.Parse(\"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee\")), Quantity.From(3)), OrdersJson.Default.OrderLine)"); + roundTrip.ShouldBeShownIn(Readme, value => value ? "true" : "false", "back == new OrderLine(OrderId.From(Guid.Parse(\"0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee\")), Quantity.From(3))"); + OrdersJson.Default.GetTypeInfo(typeof(Guid)).ShouldBeNull("the context lists no wrapped type, and the converter needs none"); + } + + [Fact] + public void Configuration_binds_the_wrapped_type_and_Create_applies() => + Usage.Configuration().ShouldBeShownIn(Readme, Quoted, "host.Match(h => h.Value, fault => fault.ToString())"); + + [Fact] + public void A_value_object_is_recognised_by_its_attribute() + { + var (described, plain) = Usage.Discovery(); + + described.ShouldBeShownIn(Readme, Quoted, "Describe(typeof(OrderId))"); + plain.ShouldBeShownIn(Readme, Quoted, "Describe(typeof(Guid))"); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs new file mode 100644 index 0000000..ee05ce2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs @@ -0,0 +1,209 @@ +using CodoMetis.TypeKit; +using System.Reflection; +using System.Text.RegularExpressions; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Options; + +// sample: option/types +public readonly partial record struct CustomerId : IValue; + +public sealed record Address(string Town, string Country); + +public sealed record Customer(CustomerId Id, string Name, string Tier, Option Nickname, Option
Address); +// end sample + +public enum LookupFault { Unknown } + +// sample: option/boundary +public sealed record CustomerDto(string Name, string? Nickname); // the wire says absent with null + +// end sample +public sealed class Shop +{ + public Customer Ann { get; } = new(CustomerId.New(), "Ann", "gold", Option.Some("Annie"), Option.Some(new Address("Berlin", "DE"))); + + public Dictionary Customers { get; } + + public List Visits { get; } = []; + + public Shop() => Customers = new Dictionary { [Ann.Id] = Ann }; + + public (Option Found, Option Unknown, Option Gold, Option Nickname, Option Parsed, Option None) Create(string text) + { + var customers = Customers; + var ann = Ann; + + // sample: option/create + Option found = customers.GetValueOrNone(ann.Id); // Some + Option unknown = customers.GetValueOrNone(CustomerId.New()); // None + Option gold = customers.Values.FirstOrNone(c => c.Tier == "gold"); // Some, the first match (or None) + Option nickname = ann.Nickname; // Some + Option parsed = int.TryParse(text, out var number) ? Option.Some(number) : Option.None(); // None, taking its type from the conditional + Option none = Option.None(); // where nothing supplies the type + // end sample + + return (found, unknown, gold, nickname, parsed, none); + } + + public (string Greeting, string Tier, int? Limit, int Sum, bool Known) Reach(Option found, Option unknown, Option parsed) + { + var visits = Visits; + + // sample: option/reach + string greeting = found.Match(customer => $"Hello again {customer.Name}", () => "Hello"); // "Hello again Ann" + string tier = unknown.Map(customer => customer.Tier).Or("standard"); // "standard" + int? limit = parsed.OrNull(); // null, an int? for an Option + int sum = found.Map(customer => customer.Name.Length).OrDefault(); // 3, a struct's default for None + bool known = found.IsSome(); // true + + if (found.TryGetValue(out var customer)) visits.Add(customer.Id); // customer is non-null in the branch + + foreach (var each in unknown.AsEnumerable()) visits.Add(each.Id); // runs once for Some, never for None + // end sample + + return (greeting, tier, limit, sum, known); + } + + public (Option Discount, Option Country, Option<(string Name, string Town)> Label, Option Seen, Option Greeting) Combine(Option found) + { + var visits = Visits; + + // sample: option/combine + Option discount = found.Filter(customer => customer.Tier == "gold").Map(_ => 0.1m); // Some, Ann is gold + Option country = found.Bind(customer => customer.Address).Map(address => address.Country); // Some, the Option
flattened and then mapped + Option<(string Name, string Town)> label = found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)); // Some, only when both are + Option seen = found.Tap(customer => visits.Add(customer.Id)); // runs for Some, hands the option on + + Option greeting = + from customer in found + from address in customer.Address // a later from sees the earlier ones + where address.Country == "DE" + select $"{customer.Name} in {address.Town}"; // Some + // end sample + + return (discount, country, label, seen, greeting); + } + + // sample: option/boundary + static Customer FromDto(CustomerDto dto) => new(CustomerId.New(), dto.Name, "standard", dto.Nickname.ToOption(), Option.None()); + + static CustomerDto ToDto(Customer customer) => new(customer.Name, customer.Nickname.OrNull()); + // end sample + + public (Customer FromWire, CustomerDto ToWire, Result Required) Boundary() + { + var customers = Customers; + var fromWire = FromDto(new CustomerDto("Bo", null)); + var toWire = ToDto(Ann); + + // sample: option/to-result + Result required = customers.GetValueOrNone(CustomerId.New()).ToResult(LookupFault.Unknown); // Error: Unknown + // end sample + + return (fromWire, toWire, required); + } +} + +public sealed class OptionTests +{ + private const string Readme = "docs/option.md"; + + private static string SomeOrNone(Option option) where T : notnull => option.IsSome() ? "Some" : "None"; + + private static string Quoted(string text) => $"\"{text}\""; + + [Fact] + public void Creating_answers_what_the_comments_say() + { + var shop = new Shop(); + var customers = shop.Customers; + var ann = shop.Ann; + var text = "x"; + + customers.GetValueOrNone(ann.Id).ShouldBeShownIn(Readme, SomeOrNone); + customers.GetValueOrNone(CustomerId.New()).ShouldBeShownIn(Readme, SomeOrNone); + customers.Values.FirstOrNone(c => c.Tier == "gold").ShouldBeShownIn(Readme, SomeOrNone); + ann.Nickname.ShouldBeShownIn(Readme, SomeOrNone); + (int.TryParse(text, out var number) ? Option.Some(number) : Option.None()).ShouldBeShownIn(Readme, SomeOrNone); + + var (found, unknown, gold, nickname, parsed, none) = shop.Create(text); + (found, unknown.IsNone(), gold, nickname, parsed.IsNone(), none.IsNone()).ShouldBe((Option.Some(ann), true, Option.Some(ann), Option.Some("Annie"), true, true)); + } + + [Fact] + public void Reaching_the_value_answers_what_the_comments_say() + { + var shop = new Shop(); + var (found, unknown, _, _, parsed, _) = shop.Create("x"); + + found.Match(customer => $"Hello again {customer.Name}", () => "Hello").ShouldBeShownIn(Readme, Quoted); + unknown.Map(customer => customer.Tier).Or("standard").ShouldBeShownIn(Readme, Quoted); + parsed.OrNull().ShouldBeShownIn(Readme, limit => limit?.ToString() ?? "null"); + found.Map(customer => customer.Name.Length).OrDefault().ShouldBeShownIn(Readme, sum => sum.ToString()); + found.IsSome().ShouldBeShownIn(Readme, known => known ? "true" : "false"); + + var (greeting, tier, limit, sum, known) = shop.Reach(found, unknown, parsed); + (greeting, tier, limit, sum, known).ShouldBe(("Hello again Ann", "standard", null, 3, true)); + shop.Visits.ShouldBe([shop.Ann.Id], "TryGetValue took the branch once, and the foreach over None ran never"); + } + + [Fact] + public void Combining_answers_what_the_comments_say() + { + var shop = new Shop(); + var (found, _, _, _, _, _) = shop.Create("x"); + + found.Filter(customer => customer.Tier == "gold").Map(_ => 0.1m).ShouldBeShownIn(Readme, SomeOrNone); + found.Bind(customer => customer.Address).Map(address => address.Country).ShouldBeShownIn(Readme, SomeOrNone); + found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)).ShouldBeShownIn(Readme, SomeOrNone); + + var (discount, country, label, seen, greeting) = shop.Combine(found); + (discount, country, label, seen, greeting).ShouldBe((Option.Some(0.1m), Option.Some("DE"), Option.Some(("Ann", "Berlin")), found, Option.Some("Ann in Berlin"))); + shop.Visits.ShouldBe([shop.Ann.Id], "Tap ran once"); + } + + [Fact] + public void The_boundary_converts_as_the_page_says() + { + var shop = new Shop(); + var customers = shop.Customers; + + customers.GetValueOrNone(CustomerId.New()).ToResult(LookupFault.Unknown) + .ShouldBeShownIn(Readme, result => result.Match(customer => $"Success: {customer.Name}", fault => $"Error: {fault}")); + + var (fromWire, toWire, required) = shop.Boundary(); + fromWire.Nickname.IsNone().ShouldBeTrue(); + toWire.ShouldBe(new CustomerDto("Ann", "Annie")); + required.TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(LookupFault.Unknown); + } + + /// + /// The page's surface table names every public member of Option<T>, of the static class + /// Option and of the extensions, read from the types, so a member added to the package without + /// a row fails here. + /// + [Fact] + public void The_surface_table_names_every_public_member() + { + var page = SampleOutputs.Readme(Readme); + var surface = page[page.IndexOf("## The surface", StringComparison.Ordinal)..]; + + var members = PublicMembers(typeof(Option<>), typeof(Option), typeof(ValueTypeOptionExtensions)); + + members.Length.ShouldBeGreaterThanOrEqualTo(20, string.Join(", ", members)); + members.Where(member => !Regex.IsMatch(surface, $@"`{Regex.Escape(member)}[`(<,]")).ShouldBeEmpty("Every public member has a row in the surface table."); + } + + /// Public method names, operators, accessors and the record's own members aside. + internal static string[] PublicMembers(params Type[] types) => + [ + .. types.SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + .Where(method => !method.IsSpecialName) + .Select(method => method.Name) + .Where(name => name is not ("Equals" or "GetHashCode" or "ToString" or "Deconstruct" or "PrintMembers")) + .Distinct() + .Order(StringComparer.Ordinal) + ]; +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs new file mode 100644 index 0000000..33385bd --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs @@ -0,0 +1,328 @@ +using CodoMetis.TypeKit; +using System.Diagnostics; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Results; + +// sample: result/types +public readonly partial record struct CartId : IValue; + +public readonly partial record struct OrderId : IValue; + +public enum CheckoutFault { CartUnknown, CartEmpty, OutOfStock, PaymentRefused } + +public sealed record Line(string Sku, int Quantity, decimal Price); + +public sealed record Cart(CartId Id, IReadOnlyList Lines); + +public sealed record Reservation(OrderId Id, decimal Total); + +public sealed record Receipt(OrderId Id, decimal Total); +// end sample + +public sealed record LineRequest(string Sku, int Quantity); + +// sample: result/api-fault +public enum ApiFault { NotFound, Invalid, Unavailable } + +static class Faults +{ + public static ApiFault ToApi(CheckoutFault fault) => fault switch + { + CheckoutFault.CartUnknown => ApiFault.NotFound, + CheckoutFault.CartEmpty or CheckoutFault.OutOfStock => ApiFault.Invalid, + CheckoutFault.PaymentRefused => ApiFault.Unavailable, + _ => throw new UnreachableException(), + }; +} +// end sample + +/// The checkout the page's examples call: two carts, a stock, a limit on one payment. +public sealed class Checkout +{ + private readonly Dictionary carts; + private readonly Dictionary stock = new() { ["A"] = (5, 10m), ["B"] = (5, 10m) }; + private readonly decimal limit = 100m; + + public Cart Cart { get; } = new(CartId.New(), [new Line("A", 2, 10m), new Line("B", 1, 10m)]); + + public Cart Empty { get; } = new(CartId.New(), []); + + public Checkout() => carts = new Dictionary { [Cart.Id] = Cart, [Empty.Id] = Empty }; + + public Result FindCart(CartId id) => carts.GetValueOrNone(id).ToResult(CheckoutFault.CartUnknown); + + public Task> FindCartAsync(CartId id) => Task.FromResult(FindCart(id)); + + private bool InStock(Cart cart) => cart.Lines.All(line => stock.TryGetValue(line.Sku, out var item) && item.Stock >= line.Quantity); + + // sample: result/two-shapes + public Result Reserve(Cart cart) // a value, or a fault + { + if (cart.Lines.Count == 0) return Result.Error(CheckoutFault.CartEmpty); + if (!InStock(cart)) return Result.Error(CheckoutFault.OutOfStock); + + return new Reservation(OrderId.New(), cart.Lines.Sum(line => line.Quantity * line.Price)); // a bare value is a success + } + + public Result Charge(decimal total) => // a success or a fault, and no value + total <= limit ? Result.Success() : CheckoutFault.PaymentRefused; // a bare error converts here, where nothing can be confused with it + // end sample + + public Task> ReserveAsync(Cart cart) => Task.FromResult(Reserve(cart)); + + public Task> ChargeAsync(decimal total) => Task.FromResult(Charge(total)); + + public Result Invoice(CartId id) => FindCart(id).Bind(_ => Result.Success()); + + public Result ToLine(LineRequest request) => + stock.TryGetValue(request.Sku, out var item) && item.Stock >= request.Quantity + ? new Line(request.Sku, request.Quantity, item.Price) + : Result.Error(CheckoutFault.OutOfStock); +} + +public static class Usage +{ + public static (string Outcome, ResultState State, Option Maybe) Reach(Checkout checkout, Cart cart, List audit) + { + // sample: result/reach + var reserved = checkout.Reserve(cart); + + string outcome = reserved.Match(reservation => $"reserved {reservation.Total}", fault => $"refused: {fault}"); // "reserved 30" + ResultState state = reserved.State; // Success + + if (reserved.TryGetValue(out var reservation, out _) && checkout.Charge(reservation.Total)) audit.Add("charged"); // a Result converts to bool; a valued one never does + + Option maybe = reserved.ToOption(); // the fault dropped, where only presence matters + // end sample + + return (outcome, state, maybe); + } + + public static (string Paid, string Ledger, string Empty, Result Settled, Result ForApi) Pipeline(Checkout checkout, CartId cartId, CartId emptyCartId, List audit) + { + // sample: result/pipeline + Result receipt = checkout.FindCart(cartId) // Result + .Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on the value: break it, and this is the error + .Bind(checkout.Reserve) // a step that can fail itself + .Map(reservation => new Receipt(reservation.Id, reservation.Total)) // a step that cannot + .Tap(r => audit.Add($"receipt {r.Total}")) // on a success only + .TapError(fault => audit.Add($"refused: {fault}")); // on an error only + + Result charged = receipt.Bind(r => checkout.Charge(r.Total)); // a command after a query + + string paid = receipt.Match(r => $"paid {r.Total}", fault => $"refused: {fault}"); // "paid 30" + string ledger = charged.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" + string empty = checkout.FindCart(emptyCartId).Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty).Bind(checkout.Reserve).Match(r => "reserved", fault => $"refused: {fault}"); // "refused: CartEmpty" + // end sample + + // sample: result/recover + Result settled = charged.Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(cartId) : fault); // Success + // end sample + + // sample: result/map-error + Result forApi = receipt.MapError(Faults.ToApi); // Success, untouched; an error would be the ApiFault + // end sample + + return (paid, ledger, empty, settled, forApi); + } + + public static async Task PipelineAsync(Checkout checkout, CartId cartId, List audit) + { + // sample: result/async + Result chargedAsync = await checkout.FindCartAsync(cartId) // Task> + .EnsureAsync(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty) // a rule on a pending result + .BindAsync(cart => checkout.ReserveAsync(cart)) // an asynchronous step + .MapAsync(reservation => reservation.Total) // a synchronous step on a pending result + .BindAsync(total => checkout.ChargeAsync(total)) // a command: Result remains + .TapErrorAsync(fault => audit.Add($"not charged: {fault}")); + + string asyncLedger = chargedAsync.Match(() => "charged", fault => $"not charged: {fault}"); // "charged" + // end sample + + return asyncLedger; + } + + public static (Result, CheckoutFault> Lines, Result, CheckoutFault> All, Result Amount, Result Bulk, string Summary, string Stopped) Collect( + Checkout checkout, Cart cart, List requested, List refused, List> reservations, Result unitPrice, Result quantity) + { + // sample: result/collect + Result, CheckoutFault> lines = requested.Traverse(checkout.ToLine); // every request to a line, or the first fault + Result, CheckoutFault> all = reservations.Sequence(); // a list of results to a result of a list + Result amount = unitPrice.Zip(quantity, (price, count) => price * count); // both successes, or the first error + Result bulk = cart.Lines.FirstOrError(line => line.Quantity >= 10, CheckoutFault.OutOfStock); // the first match, or the error + + string summary = requested.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "2 lines" + string stopped = refused.Traverse(checkout.ToLine).Match(list => $"{list.Count} lines", fault => $"refused: {fault}"); // "refused: OutOfStock" + // end sample + + return (lines, all, amount, bulk, summary, stopped); + } + + public static async Task<(Result Url, Result Narrowed, Result AsFault, Result Written, Result Read)> Catching(string text, string path) + { + // sample: result/catch + Result url = Result.Catch(() => new Uri(text)); // Error: UriFormatException + Result narrowed = Result.Catch(() => new Uri(text)); // that type only; any other propagates + Result asFault = Result.Catch(() => new Uri(text)).MapError(_ => ApiFault.Invalid); // Error: Invalid + Result written = Result.Catch(() => File.WriteAllText(path, "receipt")); // Success, a command + Result read = await Result.CatchAsync(() => File.ReadAllTextAsync(path)); // Success: receipt + // end sample + + return (url, narrowed, asFault, written, read); + } + + public static Result Query(Checkout checkout, CartId cartId) + { + // sample: result/query + Result receipt = + from cart in checkout.FindCart(cartId) + from reservation in checkout.Reserve(cart) // each from sees the earlier ranges + select new Receipt(reservation.Id, reservation.Total); + // end sample + + return receipt; + } +} + +public sealed class ResultTests +{ + private const string Readme = "docs/result.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Outcome(Result result) where T : notnull where TError : notnull => + result.Match(value => $"Success: {value}", error => $"Error: {(error is Exception e ? e.GetType().Name : error)}"); + + private static string Outcome(Result result) where TError : notnull => + result.Match(() => "Success", error => $"Error: {error}"); + + [Fact] + public void Reaching_the_outcome_answers_what_the_comments_say() + { + var checkout = new Checkout(); + var cart = checkout.Cart; + var audit = new List(); + var reserved = checkout.Reserve(cart); + + reserved.Match(reservation => $"reserved {reservation.Total}", fault => $"refused: {fault}").ShouldBeShownIn(Readme, Quoted); + reserved.State.ShouldBeShownIn(Readme, state => state.ToString()); + + var (outcome, state, maybe) = Usage.Reach(checkout, cart, audit); + (outcome, state, maybe.IsSome()).ShouldBe(("reserved 30", ResultState.Success, true)); + audit.ShouldBe(["charged"]); + + checkout.Reserve(checkout.Empty).State.ShouldBe(ResultState.Error); + checkout.Charge(1_000m).TryGetError(out var refused).ShouldBeTrue(); + refused.ShouldBe(CheckoutFault.PaymentRefused); + } + + [Fact] + public void The_pipeline_answers_what_the_comments_say() + { + var checkout = new Checkout(); + var audit = new List(); + + var (paid, ledger, empty, settled, forApi) = Usage.Pipeline(checkout, checkout.Cart.Id, checkout.Empty.Id, audit); + + paid.ShouldBeShownIn(Readme, Quoted, "receipt.Match(r => $\"paid {r.Total}\", fault => $\"refused: {fault}\")"); + ledger.ShouldBeShownIn(Readme, Quoted, "charged.Match(() => \"charged\", fault => $\"not charged: {fault}\")"); + empty.ShouldBeShownIn(Readme, Quoted, "checkout.FindCart(emptyCartId).Ensure(cart => cart.Lines.Count > 0, CheckoutFault.CartEmpty).Bind(checkout.Reserve).Match(r => \"reserved\", fault => $\"refused: {fault}\")"); + settled.ShouldBeShownIn(Readme, Outcome, "charged.Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(cartId) : fault)"); + forApi.ShouldBeShownIn(Readme, result => result.Match(_ => "Success", fault => $"Error: {fault}"), "receipt.MapError(Faults.ToApi)"); + audit.ShouldBe(["receipt 30"], "the empty cart's chain has no TapError"); + + checkout.Charge(1_000m).Recover(fault => fault == CheckoutFault.PaymentRefused ? checkout.Invoice(checkout.Cart.Id) : fault).State.ShouldBe(ResultState.Success, "Recover ran on the error"); + checkout.Reserve(checkout.Empty).MapError(Faults.ToApi).TryGetValue(out _, out var api).ShouldBeFalse(); + api.ShouldBe(ApiFault.Invalid); + } + + [Fact] + public async Task The_async_chain_answers_what_the_comment_says() + { + var checkout = new Checkout(); + var audit = new List(); + + var ledger = await Usage.PipelineAsync(checkout, checkout.Cart.Id, audit); + + ledger.ShouldBeShownIn(Readme, Quoted, "chargedAsync.Match(() => \"charged\", fault => $\"not charged: {fault}\")"); + audit.ShouldBeEmpty(); + + (await Usage.PipelineAsync(checkout, checkout.Empty.Id, audit)).ShouldBe("not charged: CartEmpty"); + audit.ShouldBe(["not charged: CartEmpty"]); + } + + [Fact] + public void Collecting_answers_what_the_comments_say() + { + var checkout = new Checkout(); + List requested = [new("A", 1), new("B", 2)]; + List refused = [new("A", 1), new("Z", 1), new("B", 2)]; + List> reservations = [checkout.Reserve(checkout.Cart), checkout.Reserve(checkout.Empty)]; + Result unitPrice = 10m; + Result quantity = 3; + + var (lines, all, amount, bulk, summary, stopped) = Usage.Collect(checkout, checkout.Cart, requested, refused, reservations, unitPrice, quantity); + + summary.ShouldBeShownIn(Readme, Quoted, "requested.Traverse(checkout.ToLine).Match(list => $\"{list.Count} lines\", fault => $\"refused: {fault}\")"); + stopped.ShouldBeShownIn(Readme, Quoted, "refused.Traverse(checkout.ToLine).Match(list => $\"{list.Count} lines\", fault => $\"refused: {fault}\")"); + lines.TryGetValue(out var list, out _).ShouldBeTrue(); + list.Select(line => line.Sku).ShouldBe(["A", "B"]); + all.TryGetValue(out _, out var sequenced).ShouldBeFalse(); + sequenced.ShouldBe(CheckoutFault.CartEmpty); + amount.TryGetValue(out var product, out _).ShouldBeTrue(); + product.ShouldBe(30m); + bulk.TryGetValue(out _, out var noBulk).ShouldBeFalse(); + noBulk.ShouldBe(CheckoutFault.OutOfStock); + } + + [Fact] + public async Task Catch_answers_what_the_comments_say() + { + var text = "not a url"; + var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var (url, narrowed, asFault, written, read) = await Usage.Catching(text, path); + + url.ShouldBeShownIn(Readme, Outcome, "Result.Catch(() => new Uri(text))"); + asFault.ShouldBeShownIn(Readme, Outcome, "Result.Catch(() => new Uri(text)).MapError(_ => ApiFault.Invalid)"); + written.ShouldBeShownIn(Readme, Outcome, "Result.Catch(() => File.WriteAllText(path, \"receipt\"))"); + read.ShouldBeShownIn(Readme, Outcome, "await Result.CatchAsync(() => File.ReadAllTextAsync(path))"); + narrowed.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeOfType(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Query_syntax_binds_each_step() + { + var checkout = new Checkout(); + + Usage.Query(checkout, checkout.Cart.Id).TryGetValue(out var receipt, out _).ShouldBeTrue(); + receipt.Total.ShouldBe(30m); + Usage.Query(checkout, CartId.New()).TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(CheckoutFault.CartUnknown); + } + + /// + /// The page's surface tables name every public member of both result shapes and of the static class + /// Result, extensions included, read from the types, so a member added without a row fails here. + /// + [Fact] + public void The_surface_tables_name_every_public_member() + { + var page = SampleOutputs.Readme(Readme); + var surface = page[page.IndexOf("## The surface", StringComparison.Ordinal)..]; + + var members = Options.OptionTests.PublicMembers(typeof(Result<,>), typeof(Result<>), typeof(Result)); + + members.Length.ShouldBeGreaterThanOrEqualTo(30, string.Join(", ", members)); + members.Where(member => !System.Text.RegularExpressions.Regex.IsMatch(surface, $@"`{System.Text.RegularExpressions.Regex.Escape(member)}[`(<,]")) + .ShouldBeEmpty("Every public member has a row in a surface table."); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs new file mode 100644 index 0000000..dc7e70d --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs @@ -0,0 +1,128 @@ +// sample: testing/usings +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.Testing; +using CodoMetis.TypeKit.ValueObjects; +// end sample +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Docs.Testing; + +// sample: testing/product-code +public enum CodeFault { Blank, NotUpperCase } + +public readonly partial record struct ProductCode : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(CodeFault.Blank); + if (value != value.ToUpperInvariant()) return Result.Error(CodeFault.NotUpperCase); + + return new ProductCode(value.Trim()); + } +} +// end sample + +// sample: testing/code-request +public sealed record CodeRequest(ProductCode Code); +// end sample + +/// The page's tests, run as written. +public sealed class ProductCodeTests +{ + // sample: testing/assertions + [Fact] + public void A_code_is_created_or_refused() + { + ProductCode code = ProductCode.Create("ABC").ShouldBeSuccess(); // the value, for the test's own assertions + code.Value.ShouldBe("ABC"); + + ProductCode.Create("abc").ShouldBeError(CodeFault.NotUpperCase); // this error, or the test fails + CodeFault fault = ProductCode.Create(" ").ShouldBeError(); // any error, handed back + fault.ShouldBe(CodeFault.Blank); + + ProductCode.TryFrom("ABC").ShouldBeSome().ShouldBe(code); + ProductCode.TryFrom("abc").ShouldBeNone(); + } + // end sample + + // sample: testing/table + [Theory] + [InlineData("ABC", null)] + [InlineData(" ABC ", null)] + [InlineData("", CodeFault.Blank)] + [InlineData(" ", CodeFault.Blank)] + [InlineData("abc", CodeFault.NotUpperCase)] + public void Create_applies_each_rule(string input, CodeFault? refused) + { + var created = ProductCode.Create(input); + + if (refused is { } fault) created.ShouldBeError(fault); + else created.ShouldBeSuccess().Value.ShouldBe(input.Trim()); + } + // end sample + + // sample: testing/ways-in + [Fact] + public void Every_way_in_applies_the_rules() + { + ProductCode.TryParse("abc", null, out _).ShouldBeFalse(); + Should.Throw(() => ProductCode.Parse("abc", null)).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => JsonSerializer.Deserialize("\"abc\"")).Message.ShouldContain("NotUpperCase"); + Should.Throw(() => ProductCode.FromKnownGood("abc")).Message.ShouldContain("NotUpperCase"); + ProductCode.FromKnownGood("ABC").Revalidate().ShouldBeSuccess(); + } + // end sample + + // sample: testing/endpoint + [Fact] + public async Task A_refused_body_is_a_400() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + await using var app = builder.Build(); + app.MapPost("/codes", (CodeRequest request) => request.Code.Value); + await app.StartAsync(TestContext.Current.CancellationToken); + + var client = app.GetTestClient(); + var accepted = await client.PostAsJsonAsync("/codes", new { code = "ABC" }, TestContext.Current.CancellationToken); + var refused = await client.PostAsJsonAsync("/codes", new { code = "abc" }, TestContext.Current.CancellationToken); + + accepted.StatusCode.ShouldBe(HttpStatusCode.OK); + refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + // end sample +} + +public static class Usage +{ + public static string Failure() + { + // sample: testing/failure + string failure = Result.Catch(() => ProductCode.Create("abc").ShouldBeSuccess()).Match(_ => "", error => error.Message); // Expected a success, but the Result is the error NotUpperCase. + // end sample + + return failure; + } +} + +public sealed class TestingTests +{ + private const string Readme = "docs/testing.md"; + + [Fact] + public void The_failure_says_what_the_outcome_was() + { + var failure = Usage.Failure(); + + failure.ShouldBe(SampleOutputs.Shown(Readme, "Result.Catch(() => ProductCode.Create(\"abc\").ShouldBeSuccess()).Match(_ => \"\", error => error.Message)")); + Should.Throw(() => ProductCode.Create("abc").ShouldBeSuccess()); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs new file mode 100644 index 0000000..04b9e1c --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs @@ -0,0 +1,131 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Text; + +// sample: text/required-optional +public sealed record ProfileRequest(string? Name, string? Note); // bound from a request: nothing enforces the non-nullability + +// end sample +// sample: text/value-objects +public enum NameFault { Blank, Hidden, TooLong } + +public readonly partial record struct DisplayName : IValidatedValue +{ + public static Result Create(string value) => + value.AsText() + .MapError(fault => fault == TextFault.Blank ? NameFault.Blank : NameFault.Hidden) // the guard's faults become the field's + .Ensure(text => text.Length <= 50, NameFault.TooLong) // a rule of this field + .Map(text => new DisplayName(text)); +} + +public readonly partial record struct SubjectId : IValidatedValue +{ + public static Result Create(string value) => value.AsIdentifier().Map(id => new SubjectId(id)); +} +// end sample + +public static class Usage +{ + public static (Result Composed, Result Folded, Result Joined, Result Hidden, Result Filler, Result Issued, Result Forged) Guards() + { + // sample: text/guards + Result composed = "Zoe\u0308".AsText(); // Success: Zoë, composed to NFC: one code point for ë + Result folded = "Jean\u2003Paul".AsText(); // Success: Jean Paul, an em space folded to a space + Result joined = "\U0001F469\u200D\U0001F4BB".AsText(); // Success: 👩‍💻, the joiner holds the sequence together + Result hidden = "Zoe\u200B".AsText(); // Error: Hidden, a zero-width space + Result filler = "\u3164".AsText(); // Error: Hidden, a Hangul filler that IsNullOrWhiteSpace accepts + Result issued = " a1b2-c3 ".AsIdentifier(); // Success: a1b2-c3, trimmed and otherwise as issued + Result forged = "sub\u200Dject".AsIdentifier(); // Error: Hidden, a joiner in an identifier + // end sample + + return (composed, folded, joined, hidden, filler, issued, forged); + } + + // sample: text/required-optional + static Result Required(string? text) => text.AsText(); // Blank is the error for a required field + + static Result, TextFault> Optional(string? text) => + text.AsText() + .Map(clean => Option.Some(clean)) + .Recover(fault => fault == TextFault.Blank ? Option.None() : Result.Error(fault)); // Blank is absence, Hidden still a refusal + // end sample + + public static (string Name, string Note, string Tagged) Fields(ProfileRequest request) + { + // sample: text/required-optional-use + string name = Required(request.Name).Match(text => text, fault => fault.ToString()); // "Blank" + string note = Optional(request.Note).Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "none" + string tagged = Optional("note\u200B").Match(text => text.Match(t => t, () => "none"), fault => fault.ToString()); // "Hidden" + // end sample + + return (name, note, tagged); + } + + public static (string Shown, string Refused, bool Equal) ValueObjects() + { + // sample: text/value-objects-use + string shown = DisplayName.Create(" Zoe\u0308\u2003Q ").Match(name => name.Value, fault => fault.ToString()); // "Zoë Q" + string refused = DisplayName.Create(new string('x', 51)).Match(name => name.Value, fault => fault.ToString()); // "TooLong" + bool equal = DisplayName.FromKnownGood("Mu\u0308ller") == DisplayName.FromKnownGood("M\u00FCller"); // true, one spelling after NFC + // end sample + + return (shown, refused, equal); + } +} + +public sealed class TextTests +{ + private const string Readme = "docs/text.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Outcome(Result result) => result.Match(text => $"Success: {text}", fault => $"Error: {fault}"); + + [Fact] + public void Each_guard_answers_what_its_comment_states() + { + "Zoe\u0308".AsText().ShouldBeShownIn(Readme, Outcome); + "Jean\u2003Paul".AsText().ShouldBeShownIn(Readme, Outcome); + "\U0001F469\u200D\U0001F4BB".AsText().ShouldBeShownIn(Readme, Outcome); + "Zoe\u200B".AsText().ShouldBeShownIn(Readme, Outcome); + "\u3164".AsText().ShouldBeShownIn(Readme, Outcome); + " a1b2-c3 ".AsIdentifier().ShouldBeShownIn(Readme, Outcome); + "sub\u200Dject".AsIdentifier().ShouldBeShownIn(Readme, Outcome); + + var (composed, _, _, _, _, _, _) = Usage.Guards(); + composed.TryGetValue(out var zoe, out _).ShouldBeTrue(); + zoe.ShouldBe("Zoë", "composed: one code point"); + zoe.Length.ShouldBe(3); + string.IsNullOrWhiteSpace("\u3164").ShouldBeFalse("IsNullOrWhiteSpace takes the filler for text, which is why the guard exists"); + } + + [Fact] + public void A_required_and_an_optional_field_answer_what_the_comments_say() + { + var (name, note, tagged) = Usage.Fields(new ProfileRequest(null, " ")); + + name.ShouldBeShownIn(Readme, Quoted, "Required(request.Name).Match(text => text, fault => fault.ToString())"); + note.ShouldBeShownIn(Readme, Quoted, "Optional(request.Note).Match(text => text.Match(t => t, () => \"none\"), fault => fault.ToString())"); + tagged.ShouldBeShownIn(Readme, Quoted, "Optional(\"note\\u200B\").Match(text => text.Match(t => t, () => \"none\"), fault => fault.ToString())"); + + Usage.Fields(new ProfileRequest(" Ann ", " a note ")).ShouldBe(("Ann", "a note", "Hidden")); + } + + [Fact] + public void A_value_object_over_the_guard_answers_what_the_comments_say() + { + var (shown, refused, equal) = Usage.ValueObjects(); + + shown.ShouldBeShownIn(Readme, Quoted, "DisplayName.Create(\" Zoe\\u0308\\u2003Q \").Match(name => name.Value, fault => fault.ToString())"); + refused.ShouldBeShownIn(Readme, Quoted, "DisplayName.Create(new string('x', 51)).Match(name => name.Value, fault => fault.ToString())"); + equal.ShouldBeShownIn(Readme, e => e ? "true" : "false", "DisplayName.FromKnownGood(\"Mu\\u0308ller\") == DisplayName.FromKnownGood(\"M\\u00FCller\")"); + + DisplayName.TryFrom("\u200B").IsNone().ShouldBeTrue(); + DisplayName.Create("").TryGetValue(out _, out var blank).ShouldBeFalse(); + blank.ShouldBe(NameFault.Blank); + SubjectId.Create(" sub-1 ").Match(id => id.Value, fault => fault.ToString()).ShouldBe("sub-1"); + SubjectId.Create("sub\u200D1").TryGetValue(out _, out var joiner).ShouldBeFalse(); + joiner.ShouldBe(TextFault.Hidden); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TypeConstraintsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TypeConstraintsSamples.cs new file mode 100644 index 0000000..06b2cb4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/TypeConstraintsSamples.cs @@ -0,0 +1,111 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.TypeConstraints; + +#pragma warning disable CMTK0010 // what the page shows the rule reporting +// sample: type-constraints/aggregate +public abstract record AccountEvent; + +public sealed record Deposited(decimal Amount) : AccountEvent; + +public readonly partial record struct AccountId : IValue; + +public abstract class Aggregate<[Abstract] TEvent, [Sealed] TId> +{ + public List Pending { get; } = []; +} + +public sealed class Account : Aggregate; // fine: an abstract record, a struct + +public sealed class Ledger : Aggregate; // CMTK0010: Deposited is not abstract +// end sample + +// sample: type-constraints/inferred +public static class Snapshots +{ + public static string Describe<[Record] TState>(TState state) => state!.ToString()!; // a record prints its members +} + +public sealed class Balance { public decimal Amount { get; init; } } + +public sealed record Statement(decimal Amount); +// end sample + +// sample: type-constraints/forwarding +public abstract class Leaky : Aggregate; // CMTK0010: TEvent does not carry [Abstract] + +public abstract class Sound<[Abstract] TEvent> : Aggregate; // the requirement follows the chain + +public sealed class Sealed : Aggregate where T : struct; // struct proves [Sealed], and [Concrete] +// end sample +#pragma warning restore CMTK0010 + +#pragma warning disable CMTK0011 // what the page shows the rule reporting +#pragma warning disable CS9113 // the constructor's parameter is the point, and unused +// sample: type-constraints/constructor +[RequireParameterlessConstructor] +public interface IJob +{ + Task RunAsync(CancellationToken cancellationToken); +} + +[RequireParameterlessConstructor(ConstructorAccessibility.Internal)] +public abstract class Plugin; + +public sealed class Nightly : IJob // fine: no constructor declared, so the implicit one is public +{ + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Report : IJob // CMTK0011: no parameterless constructor; the fix adds one +{ + public Report(TimeProvider clock) { } + + public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed class Importer : Plugin; // CMTK0011: the implicit constructor is public, Plugin requires internal +// end sample +#pragma warning restore CS9113 +#pragma warning restore CMTK0011 + +public static class Usage +{ + public static (string Shown, string Hidden) Inferred() + { +#pragma warning disable CMTK0010 // what the page shows the rule reporting + // sample: type-constraints/inferred-use + string shown = Snapshots.Describe(new Statement(10m)); // "Statement { Amount = 10 }" + string hidden = Snapshots.Describe(new Balance()); // CMTK0010: Balance is not a record, inferred at the call + // end sample +#pragma warning restore CMTK0010 + + return (shown, hidden); + } +} + +public sealed class TypeConstraintsTests +{ + private const string Readme = "docs/type-constraints.md"; + + [Fact] + public void A_record_prints_as_the_comment_says() + { + var (shown, hidden) = Usage.Inferred(); + + shown.ShouldBeShownIn(Readme, text => $"\"{text}\"", "Snapshots.Describe(new Statement(10m))"); + hidden.ShouldBe("ReadmeSamples.Docs.TypeConstraints.Balance", "a class prints its name, which is what the rule guards against"); + } + + /// The attributes carry no run-time check: the types the page marks as reported still exist and run. + [Fact] + public void The_guarantee_is_the_build() + { + new Ledger().Pending.ShouldBeEmpty(); + new Account().Pending.Add(new Deposited(1m)); + typeof(Aggregate<,>).GetGenericArguments()[0].GetCustomAttributes(typeof(AbstractAttribute), false).ShouldNotBeEmpty(); + typeof(Report).GetConstructor(Type.EmptyTypes).ShouldBeNull("what CMTK0011 reports"); + typeof(Nightly).GetConstructor(Type.EmptyTypes).ShouldNotBeNull(); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ValueObjectsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ValueObjectsSamples.cs new file mode 100644 index 0000000..ffd3d4a --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/ValueObjectsSamples.cs @@ -0,0 +1,332 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +using System.Diagnostics; +using System.Numerics; + +namespace ReadmeSamples.Docs.ValueObjects; + +// sample: value-objects/struct-or-class +public readonly partial record struct Sku : IValue; // a struct: never null, and default is CMTK0001 + +public sealed partial record Note : IValue; // a class: for a string column in an EF Core compiled model +// end sample + +// sample: value-objects/identifiers +public readonly partial record struct OrderId : IValue; + +public readonly partial record struct InvoiceNo : IValue; +// end sample + +// sample: value-objects/email +public enum EmailFault { Blank, NoAt, TooLong } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) + { + if (string.IsNullOrWhiteSpace(value)) return Result.Error(EmailFault.Blank); + + var normalised = value.Trim().ToLowerInvariant(); // equal addresses hold the same wrapped value + if (!normalised.Contains('@')) return Result.Error(EmailFault.NoAt); + if (normalised.Length > 254) return Result.Error(EmailFault.TooLong); + + return new Email(normalised); // the private constructor is generated + } + + public string Domain => Value[(Value.IndexOf('@') + 1)..]; // computed from Value: fine. A second field is CMTK1012 +} +// end sample + +// sample: value-objects/fault-with-data +public abstract record QuantityFault +{ + public sealed record NotPositive : QuantityFault; + + public sealed record AboveLimit(int Limit) : QuantityFault; // the limit, never the value given +} + +public readonly partial record struct Quantity : IValidatedValue +{ + public const int Limit = 1_000; + + public static Result Create(int value) + { + if (value <= 0) return Result.Error(new QuantityFault.NotPositive()); + if (value > Limit) return Result.Error(new QuantityFault.AboveLimit(Limit)); + + return new Quantity(value); + } +} +// end sample + +// sample: value-objects/numbers +public readonly partial record struct Weight : IValue; // any decimal, with MinValue and MaxValue + +public enum PercentageFault { BelowZero, AboveHundred } + +public readonly partial record struct Percentage : IValidatedValue +{ + public static Result Create(int value) + { + if (value < 0) return Result.Error(PercentageFault.BelowZero); + if (value > 100) return Result.Error(PercentageFault.AboveHundred); + + return new Percentage(value); + } + + public override string ToString() => $"{Value} %"; // kept; then no formatting interface is generated, so interpolation reaches it too +} +// end sample + +// sample: value-objects/other-wrapped-types +public readonly partial record struct Weekday : IValue; + +public readonly partial record struct DueOn : IValue; + +public readonly partial record struct Homepage : IValue; +// end sample + +// sample: value-objects/own-compare-to +public readonly partial record struct Priority : IValue +{ + public int CompareTo(Priority other) => other.Value.CompareTo(Value); // 1 sorts before 2; the operators follow +} +// end sample + +// sample: value-objects/nested +public sealed partial class Shipment +{ + public readonly partial record struct Id : IValue; // Shipment.Id; its companion class is ShipmentIdExtensions + + public required Id Key { get; init; } +} +// end sample + +/// The request the factory sample reads: a bound shape, whose text is input. +public sealed record RegisterRequest(string Email); + +public static class Usage +{ + public static (OrderId Id, OrderId Imported, InvoiceNo No, int Version) Identifiers() + { + // sample: value-objects/identifiers-use + var id = OrderId.New(); // a version 7 Guid, which sorts by creation time + var imported = OrderId.New(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // its timestamp, for a migrated record + var no = InvoiceNo.From(42); + + int version = id.Value.Version; // 7 + // end sample + + return (id, imported, no, version); + } + + public static (string Lowered, bool Same, string Domain) Emails() + { + // sample: value-objects/email-use + string lowered = Email.Create(" Ops@Example.com ").Match(email => email.Value, fault => fault.ToString()); // "ops@example.com" + bool same = Email.FromKnownGood("Ops@Example.com") == Email.FromKnownGood("ops@example.com"); // true + string domain = Email.FromKnownGood("ops@example.com").Domain; // "example.com" + // end sample + + return (lowered, same, domain); + } + + // sample: value-objects/fault-with-data-use + static string Describe(QuantityFault fault) => fault switch + { + QuantityFault.NotPositive => "must be positive", + QuantityFault.AboveLimit { Limit: var limit } => $"at most {limit}", + _ => throw new UnreachableException(), + }; + + // end sample + public static string Quantities() + { + // sample: value-objects/fault-with-data-use + string explained = Quantity.Create(5_000).Match(quantity => quantity.ToString(), Describe); // "at most 1000" + // end sample + + return explained; + } + + public static (bool Widest, string Shown, bool Ordered) Numbers() + { + // sample: value-objects/numbers-use + bool widest = Weight.MaxValue.Value == decimal.MaxValue; // true + string shown = $"{Percentage.FromKnownGood(42)}"; // "42 %" + bool ordered = Percentage.FromKnownGood(5) < Percentage.FromKnownGood(50); // true, the wrapped type's ordering + // end sample + + return (widest, shown, ordered); + } + + public static (Weekday Day, Weekday Fifth, DueOn Due, bool Sooner, bool Sortable) OtherWrappedTypes() + { + // sample: value-objects/other-wrapped-types-use + var day = Weekday.Parse("Friday", null); // Friday + var fifth = Weekday.Parse("5", null); // Friday, as Enum.TryParse reads a number + var due = DueOn.Parse("2026-10-13", null); // 2026-10-13 + bool sooner = DueOn.From(new DateOnly(2026, 1, 1)) < due; // true + bool sortable = typeof(IComparable).IsAssignableFrom(typeof(Homepage)); // false, a Uri has no ordering + // end sample + + return (day, fifth, due, sooner, sortable); + } + + public static void Factories(RegisterRequest request) + { + // sample: value-objects/factories + Result fromRequest = Email.Create(request.Email); // the fault says which rule refused it + Option maybe = Email.TryFrom(request.Email); // is it valid? + Email support = Email.FromKnownGood("support@example.com"); // a refusal here is a bug at this line + InvoiceNo first = InvoiceNo.From(1); // no rules + Result stillValid = support.Revalidate(); // today's rules, applied again + // end sample + + _ = (fromRequest, maybe, first, stillValid); + } + + public static (Priority First, bool Urgent) Priorities() + { + // sample: value-objects/own-compare-to-use + var first = new[] { Priority.From(3), Priority.From(1) }.Min(); // 3 + bool urgent = Priority.From(1) > Priority.From(2); // true + // end sample + + return (first, urgent); + } + + // sample: value-objects/generic + static Result, TFault> CreateAll(IEnumerable values) + where TSelf : IValidatedValue + where T : notnull + where TFault : notnull => + values.Traverse(TSelf.Create); // the first fault stops it; nothing after it is created + + // end sample + public static string Generic() + { + // sample: value-objects/generic-use + string outcome = CreateAll(["ops@example.com", "nobody"]).Match(list => $"{list.Count} valid", fault => $"refused: {fault}"); // "refused: NoAt" + // end sample + + return outcome; + } +} + +public sealed class ValueObjectsTests +{ + private const string Readme = "docs/value-objects.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Bool(bool value) => value ? "true" : "false"; + + [Fact] + public void An_identifier_is_what_the_comments_say() + { + var (id, imported, no, version) = Usage.Identifiers(); + + id.Value.Version.ShouldBeShownIn(Readme, v => v.ToString()); + version.ShouldBe(7); + imported.Value.Version.ShouldBe(7); + no.Value.ShouldBe(42); + Sku.From("A-1").ShouldBe(Sku.From("A-1")); + Note.From("n").ShouldBe(Note.From("n")); + } + + [Fact] + public void An_email_is_normalised_as_the_comments_say() + { + var (lowered, same, domain) = Usage.Emails(); + + Email.Create(" Ops@Example.com ").Match(email => email.Value, fault => fault.ToString()).ShouldBeShownIn(Readme, Quoted); + (Email.FromKnownGood("Ops@Example.com") == Email.FromKnownGood("ops@example.com")).ShouldBeShownIn(Readme, Bool); + Email.FromKnownGood("ops@example.com").Domain.ShouldBeShownIn(Readme, Quoted); + (lowered, same, domain).ShouldBe(("ops@example.com", true, "example.com")); + + Email.Create("").TryGetValue(out _, out var blank).ShouldBeFalse(); + blank.ShouldBe(EmailFault.Blank); + Email.Create(new string('a', 250) + "@x.test").TryGetValue(out _, out var tooLong).ShouldBeFalse(); + tooLong.ShouldBe(EmailFault.TooLong); + } + + [Fact] + public void A_fault_carries_the_rule_s_data_as_the_comment_says() + { + Usage.Quantities().ShouldBeShownIn(Readme, Quoted, "Quantity.Create(5_000).Match(quantity => quantity.ToString(), Describe)"); + + Quantity.Create(0).TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBeOfType(); + Quantity.Create(5_000).TryGetValue(out _, out var above).ShouldBeFalse(); + above.ShouldBe(new QuantityFault.AboveLimit(1_000)); + } + + [Fact] + public void Numbers_are_what_the_comments_say() + { + var (widest, shown, ordered) = Usage.Numbers(); + + (Weight.MaxValue.Value == decimal.MaxValue).ShouldBeShownIn(Readme, Bool); + $"{Percentage.FromKnownGood(42)}".ShouldBeShownIn(Readme, Quoted); + (Percentage.FromKnownGood(5) < Percentage.FromKnownGood(50)).ShouldBeShownIn(Readme, Bool); + (widest, shown, ordered).ShouldBe((true, "42 %", true)); + + typeof(Percentage).GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMinMaxValue<>)).ShouldBeFalse("a validated value object has no MinValue and MaxValue"); + typeof(Weight).GetInterfaces().ShouldContain(typeof(IMinMaxValue)); + Weight.From(1.5m).ToString().ShouldBe("1.5"); + } + + [Fact] + public void Other_wrapped_types_parse_and_compare_as_the_comments_say() + { + var (day, fifth, due, sooner, sortable) = Usage.OtherWrappedTypes(); + + Weekday.Parse("Friday", null).ShouldBeShownIn(Readme, d => d.Value.ToString()); + Weekday.Parse("5", null).ShouldBeShownIn(Readme, d => d.Value.ToString()); + DueOn.Parse("2026-10-13", null).ShouldBeShownIn(Readme, d => d.Value.ToString("yyyy-MM-dd")); + (DueOn.From(new DateOnly(2026, 1, 1)) < due).ShouldBeShownIn(Readme, Bool); + typeof(IComparable).IsAssignableFrom(typeof(Homepage)).ShouldBeShownIn(Readme, Bool); + (day, fifth, sooner, sortable).ShouldBe((Weekday.From(DayOfWeek.Friday), Weekday.From(DayOfWeek.Friday), true, false)); + Homepage.Parse("https://example.com/", null).Value.ShouldBe(new Uri("https://example.com/")); + } + + [Fact] + public void The_factories_answer_as_the_table_says() + { + Usage.Factories(new RegisterRequest("nobody")); + + Email.Create("nobody").TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(EmailFault.NoAt); + Email.TryFrom("nobody").IsNone().ShouldBeTrue(); + var typed = "nobody"; + var refusal = Should.Throw(() => Email.FromKnownGood(typed)).Message; + refusal.ShouldContain("typed", Case.Sensitive, "the call site's expression"); + refusal.ShouldNotContain("nobody", Case.Sensitive, "never the value"); + Email.FromKnownGood("support@example.com").Revalidate().TryGetValue(out var revalidated, out _).ShouldBeTrue(); + revalidated.ShouldBe(Email.FromKnownGood("support@example.com")); + } + + [Fact] + public void A_hand_written_CompareTo_orders_as_the_comments_say() + { + var (first, urgent) = Usage.Priorities(); + + new[] { Priority.From(3), Priority.From(1) }.Min().ShouldBeShownIn(Readme, p => p.Value.ToString()); + (Priority.From(1) > Priority.From(2)).ShouldBeShownIn(Readme, Bool); + (first, urgent).ShouldBe((Priority.From(3), true)); + } + + [Fact] + public void A_nested_value_object_has_its_companion_named_after_the_chain() + { + var shipment = new Shipment { Key = Shipment.Id.New() }; + + shipment.Key.Value.Version.ShouldBe(7); + typeof(Shipment).Assembly.GetType("ReadmeSamples.Docs.ValueObjects.ShipmentIdExtensions").ShouldNotBeNull(); + } + + [Fact] + public void Generic_code_stops_at_the_first_fault_as_the_comment_says() => + Usage.Generic().ShouldBeShownIn(Readme, Quoted, "CreateAll([\"ops@example.com\", \"nobody\"]).Match(list => $\"{list.Count} valid\", fault => $\"refused: {fault}\")"); +} From 09d8b78df0410c27a88bcd65fc5e16af5adb2857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 11:53:26 +0200 Subject: [PATCH 016/147] feat: the closed-set family, IClosedSet generated by CodoMetis.TypeKit.Generators A sealed partial class whose every instance is a public static readonly field, with constant data through a private constructor. The aspect adds Name from the field, QualifiedName, All in declaration order, Match with one required delegate per member, TryParse and Parse by name, ToString, a JSON converter that writes the name and reads the member back, a private constructor where none is declared, and the discovery attribute. CMTK1013 to CMTK1018 refuse a declaration that cannot be generated. The EF satellite maps a set to a text column of the name, the OpenAPI satellite to a string enum. Seeded: without the names initializer five of nine tests fail. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 ++ docs/plan.md | 8 +- .../ClosedSetSchemaTransformer.cs | 33 ++++ .../TypeKitOpenApiOptionsExtensions.cs | 1 + .../ClosedSetMapping.cs | 52 +++++++ .../ValueObjectTypeMappingSourcePlugin.cs | 10 ++ .../AspectDiagnostics.cs | 18 +++ .../AspectOrder.cs | 3 +- .../ClosedSetAspect.Templates.cs | 116 ++++++++++++++ .../ClosedSetAspect.cs | 146 ++++++++++++++++++ .../ClosedSetTypes.cs | 21 +++ src/CodoMetis.TypeKit.Generators/README.md | 56 ++++++- .../ValueObjectFabric.cs | 8 +- .../ClosedSets/IClosedSet.cs | 52 +++++++ .../GeneratedClosedSetAttribute.cs | 60 +++++++ .../CompilerServices/GeneratedClosedSets.cs | 21 +++ src/CodoMetis.TypeKit/README.md | 10 +- src/CodoMetis.TypeKit/Refusals.cs | 3 + .../ClosedSetSchemaTests.cs | 29 ++++ .../ClosedSetMappingTests.cs | 61 ++++++++ .../Probes.cs | 26 ++++ .../BuildOutcomeTests.cs | 30 +++- .../ClosedSetTests.cs | 112 ++++++++++++++ .../GeneratedSurface.verified.txt | 28 ++++ .../GeneratedSurfaceTests.cs | 2 +- .../Generators/ClosedSetSamples.cs | 58 +++++++ .../RefusalLinkTests.cs | 3 + 27 files changed, 967 insertions(+), 10 deletions(-) create mode 100644 src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs create mode 100644 src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs create mode 100644 src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs create mode 100644 src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs create mode 100644 src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs create mode 100644 src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs create mode 100644 src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSetAttribute.cs create mode 100644 src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs create mode 100644 test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Generators/ClosedSetSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 99eae28..350d04a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,16 @@ Semantic Versioning reserves for a major, listed under Changed as they land. task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through every overload, a delegate that returns a `Task` given to `Catch` is a compile error that names `CatchAsync`, and a delegate that returns null throws as `Result.Success(null)` does. +- **The closed-set family**, the release's headline: `IClosedSet` in `CodoMetis.TypeKit`, generated + by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static + readonly` field, with constant data through a private constructor where the members carry some. The + generators add `Name`, `QualifiedName`, `All` in declaration order, `Match` with one required delegate + per member, `TryParse` and `Parse` by name, `ToString()`, a JSON converter that writes the name and + reads the member itself back, a private constructor where none is declared, and the attribute the + satellites read. CMTK1013 to CMTK1018 refuse a declaration that is not partial, not sealed, without + members, with a public constructor, with another public static member of its own type, or generic. + `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name; + `CodoMetis.TypeKit.AspNetCore` describes it as a string enum. Nothing is registered per set. - **`CodoMetis.TypeKit`**: `Patch`, a field of a JSON Merge Patch (RFC 7396): absent, null or a value, where System.Text.Json alone cannot tell an absent property from a null one. `default` is absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never diff --git a/docs/plan.md b/docs/plan.md index 0113ca1..3ed028f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -585,7 +585,13 @@ Each phase ends green, and its guards have been proven by seeding the defect class is listed in the context. The document inlines a patch as its argument's schema, never required, always admitting null; a value object inside a patch is described through what it wraps, since a source-generated host has no contract for it. - 7. **The closed-set family**, after its spike: the generators, then each satellite. + 7. **The closed-set family** ✅ 2026-10-13, after [spikes/ClosedSets](../spikes/ClosedSets/README.md), + which answered the compatibility question: a generated type with static members on Metalama + 2026.1 and `net10.0`, nothing newer needed. `IClosedSet` in the base package, + `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1018, the + discovery attribute `GeneratedClosedSetAttribute` with a typed visitor, a text column in + the EF satellite and a string enum in the OpenAPI satellite. The fault bits (a kind and a + sentence per member) are step 8's, since the fault responses read them. 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict diff --git a/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs new file mode 100644 index 0000000..e6ebfbc --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; +using System.Reflection; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// A closed set in the document: a string enum of its members' names, as an enum is +/// described, read from the attribute the generators put on the set, never by reflection over +/// its members. +/// +internal sealed class ClosedSetSchemaTransformer : IOpenApiSchemaTransformer +{ + private static readonly ConcurrentDictionary Descriptions = new(); + + public static GeneratedClosedSetAttribute? Describe(Type type) => + Descriptions.GetOrAdd(type, static candidate => + candidate.GetCustomAttribute(inherit: false) is { } set && set.SetType == candidate ? set : null); + + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (Describe(context.JsonTypeInfo.Type) is not { } set) return Task.CompletedTask; + + schema.Properties?.Clear(); + schema.Required?.Clear(); + schema.Type = JsonSchemaType.String; + schema.Enum = [.. set.Names.Select(name => (System.Text.Json.Nodes.JsonNode)name)]; + + return Task.CompletedTask; + } +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs index 65f5a7e..2a76aec 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs @@ -47,6 +47,7 @@ public static OpenApiOptions AddTypeKit(this OpenApiOptions options) return options.AddSchemaTransformer() .AddSchemaTransformer() + .AddSchemaTransformer() .AddOperationTransformer(); } } diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs new file mode 100644 index 0000000..abc35b4 --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; +using System.Reflection; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// A closed set as a column: its member's name, read back with the set's own TryParse. +/// Made per set through , never by +/// MakeGenericType, so it works under Native AOT as the value-object mappings do. +/// +internal abstract class ClosedSetMapping +{ + private static readonly ConcurrentDictionary Descriptions = new(); + private static readonly ConcurrentDictionary Mappings = new(); + + /// The set's attribute, or null for any other type. + public static GeneratedClosedSetAttribute? Describe(Type type) => + Descriptions.GetOrAdd(type, static candidate => + candidate.GetCustomAttribute(inherit: false) is { } set && set.SetType == candidate ? set : null); + + public static ClosedSetMapping For(GeneratedClosedSetAttribute set) => + Mappings.GetOrAdd(set.SetType, static (_, description) => description.Accept(Factory.Instance), set); + + public abstract ValueConverter Converter { get; } + + public RelationalTypeMapping ComposeOnto(RelationalTypeMapping text) => + (RelationalTypeMapping)text.WithComposedConverter(Converter, jsonValueReaderWriter: null); + + private sealed class Of : ClosedSetMapping where TSet : class, IClosedSet + { + // Plain static methods: an expression tree can call neither a static abstract member nor an out variable. + public override ValueConverter Converter { get; } = new ValueConverter(member => NameOf(member), name => MemberOf(name)); + + private static string NameOf(TSet member) => member.Name; + + private static TSet MemberOf(string name) => + TSet.TryParse(name, out var member) + ? member + : throw new InvalidOperationException($"{typeof(TSet).Name} has no member of that name: the column holds a name the set no longer has."); + } + + private sealed class Factory : IClosedSetVisitor + { + public static readonly Factory Instance = new(); + + public ClosedSetMapping Visit() where TSet : class, IClosedSet => new Of(); + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs index 3afdb0b..d04ed86 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypeMappingSourcePlugin.cs @@ -34,6 +34,16 @@ internal sealed class ValueObjectTypeMappingSourcePlugin(IServiceProvider servic { if (mappingInfo.ClrType is not { } clrType) return null; + // A closed set is a text column holding the member's name. + if (ClosedSetMapping.Describe(clrType) is { } set) + { + _typeMappingSource ??= services.GetRequiredService(); + var text = _typeMappingSource.FindMapping( + typeof(string), mappingInfo.StoreTypeName, mappingInfo.IsKeyOrIndex, mappingInfo.IsUnicode, mappingInfo.Size, + mappingInfo.IsRowVersion, mappingInfo.IsFixedLength, mappingInfo.Precision, mappingInfo.Scale); + return text is null ? null : ClosedSetMapping.For(set).ComposeOnto(text); + } + if (ValueObjectTypes.Describe(Nullable.GetUnderlyingType(clrType) ?? clrType) is not { } valueObject) return null; _typeMappingSource ??= services.GetRequiredService(); diff --git a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs index c62d1ad..5e6f081 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs @@ -56,4 +56,22 @@ internal static class AspectDiagnostics public static readonly DiagnosticDefinition<(INamedType Type, string Members)> StateBesideTheWrappedValue = new("CMTK1012", Severity.Error, "'{0}' holds {1} besides its wrapped value, so it is not generated: its JSON, parsing, type converter and EF Core column carry the wrapped value alone, so the rest is lost on every round trip, and the record's equality compares it too. Compute it from Value in a property without a backing field, make it static, or wrap a type of your own that holds all of it." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetNotPartial = + new("CMTK1013", Severity.Error, "'{0}' is a closed set and must be declared partial." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetNotSealed = + new("CMTK1014", Severity.Error, "'{0}' is a closed set and must be declared sealed, so it is not generated: a set with subtypes is not closed." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetNoMembers = + new("CMTK1015", Severity.Error, "'{0}' declares no member, so it is not generated: a member is a public static readonly field of the set's own type." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetPublicConstructor = + new("CMTK1016", Severity.Error, "'{0}' has a public constructor, so it is not generated: an instance beside its members could be made. Make the constructor private." + SeeBuildErrors); + + public static readonly DiagnosticDefinition<(INamedType, string, string)> ClosedSetMemberNotAField = + new("CMTK1017", Severity.Error, "'{0}.{1}' is {2} of the set's own type, so '{0}' is not generated: a member is a public static readonly field, and nothing else of that type is public and static." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetGeneric = + new("CMTK1018", Severity.Error, "'{0}' is generic or nested in a generic type, so it is not generated: a closed set's members are named instances, which a type parameter cannot have." + SeeBuildErrors); } diff --git a/src/CodoMetis.TypeKit.Generators/AspectOrder.cs b/src/CodoMetis.TypeKit.Generators/AspectOrder.cs index 5ee61c3..569a5e6 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectOrder.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectOrder.cs @@ -14,5 +14,6 @@ typeof(ValueObjectMinMaxValueAspect), typeof(ValueObjectTypeConverterAspect), typeof(ValueObjectConvertibleAspect), - typeof(ValueObjectCompanionAspect) + typeof(ValueObjectCompanionAspect), + typeof(ClosedSetAspect) )] diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs new file mode 100644 index 0000000..8cd0902 --- /dev/null +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using Metalama.Framework.Aspects; + +namespace CodoMetis.TypeKit.Generators; + +internal sealed partial class ClosedSetAspect +{ +#pragma warning disable CS0649, CS8618 // template fields: the generated ones are assigned by generated code + [Template] public string _name; + [Template] public static IReadOnlyList _all; +#pragma warning restore CS0649, CS8618 + + [Template] + public void PrivateConstructorTemplate() + { + } + + [Template] + public string NameTemplate + { + get => meta.This._name; + } + + [Template] + public string QualifiedNameTemplate + { + get => meta.Target.Type.Name + "." + meta.This._name; + } + + [Template] + public string ToStringTemplate() => meta.This._name; + + /// After the static field initializers, before any static constructor body: each member learns its name. + [Template] + public static void NamesInitializerTemplate() + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var member in args.Members) + { + member.Value!._name = member.Name; + } + } + + [Template] + public static dynamic? AllTemplate + { + get => meta.ThisType._all; + } + + [Template] + public static bool TryParseTemplate(string? name, out dynamic? member) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) + { + member = candidate.Value; + return true; + } + } + + member = null; + return false; + } + + /// A naming the set, never the input. + [Template] + public static dynamic? ParseTemplate(string? name) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value; + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); + } + + [Template] + public TResult MatchTemplate(Func first) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var index = meta.CompileTime(0); + foreach (var member in args.Members) + { + if (ReferenceEquals(meta.This, member.Value)) return meta.Target.Parameters[index].Value!.Invoke(); + index++; + } + + throw new InvalidOperationException(args.Set.Name + ": not a member of the set."); + } + + /// A naming the set, never the input; a JSON null is the serializer's own refusal. + [Template] + public dynamic? JsonReadTemplate(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var name = reader.TokenType == JsonTokenType.String ? reader.GetString() : null; + if (name is not null) + { + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value; + } + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson(args.Set.Name); + } + + [Template] + public void JsonWriteTemplate(Utf8JsonWriter writer, dynamic? value, JsonSerializerOptions options) + { + writer.WriteStringValue((string)value!.Name); + } +} diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs new file mode 100644 index 0000000..29f19cc --- /dev/null +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -0,0 +1,146 @@ +using System.Text.Json.Serialization; +using CodoMetis.TypeKit.CompilerServices; +using Metalama.Framework.Advising; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; +using Metalama.Framework.Code.DeclarationBuilders; +using Metalama.Framework.Code.SyntaxBuilders; +using Metalama.Framework.Diagnostics; +using static CodoMetis.TypeKit.Generators.AspectDiagnostics; + +namespace CodoMetis.TypeKit.Generators; + +/// What the templates read: the set and its members. +[CompileTime] +internal sealed class ClosedSetArguments +{ + public required INamedType Set { get; init; } + public required IReadOnlyList Members { get; init; } +} + +/// +/// Generates a closed set: Name, QualifiedName, All, Match, TryParse, +/// Parse, ToString(), the JSON converter, the discovery attribute, and a private +/// constructor where the type declares none. Applied by . +/// +/// +/// +/// A member's name is assigned by an initializer that runs after the static field initializers and +/// before any static constructor body, so the declaration needs no nameof. Match's +/// parameters are added per member; the template declares one Func<TResult> parameter, +/// whose bound type the others copy, since Metalama 2026.1 cannot build a generic instance over the +/// method builder's own type parameter (spikes/ClosedSets). +/// +/// +internal sealed partial class ClosedSetAspect : TypeAspect +{ + public override void BuildAspect(IAspectBuilder builder) + { + var target = builder.Target; + var members = ClosedSetTypes.Members(target); + var problems = new List(); + + if (!target.IsPartial) problems.Add(ClosedSetNotPartial.WithArguments(target)); + if (!target.IsSealed) problems.Add(ClosedSetNotSealed.WithArguments(target)); + if (target.IsGeneric || target.DeclaringType is { IsGeneric: true }) problems.Add(ClosedSetGeneric.WithArguments(target)); + if (target.Constructors.Any(c => !c.IsImplicitlyDeclared && c.Accessibility == Accessibility.Public)) problems.Add(ClosedSetPublicConstructor.WithArguments(target)); + if (members.Count == 0) problems.Add(ClosedSetNoMembers.WithArguments(target)); + + foreach (var field in target.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(target) && f.Writeability != Writeability.ConstructorOnly)) + problems.Add(ClosedSetMemberNotAField.WithArguments((target, field.Name, "a static field that is not readonly"))); + foreach (var property in target.Properties.Where(p => p.IsStatic && p.Accessibility == Accessibility.Public && p.Type.Equals(target))) + problems.Add(ClosedSetMemberNotAField.WithArguments((target, property.Name, "a static property"))); + + if (problems.Count > 0) + { + foreach (var problem in problems) builder.Diagnostics.Report(problem); + builder.SkipAspect(); + return; + } + + builder.Tags = new ClosedSetArguments { Set = target, Members = members }; + + if (!target.Constructors.Any(c => !c.IsImplicitlyDeclared)) + { + builder.IntroduceConstructor(nameof(PrivateConstructorTemplate), buildConstructor: c => + { + c.Accessibility = Accessibility.Private; + c.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + } + + // Assigned by the initializer below, after every member exists; null! keeps the consumer's + // nullable analysis quiet about the constructor. + builder.IntroduceField(nameof(_name), IntroductionScope.Instance, OverrideStrategy.Fail, f => + { + f.Accessibility = Accessibility.Private; + f.InitializerExpression = ExpressionFactory.Parse("null!"); + f.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceProperty(nameof(NameTemplate), buildProperty: p => { p.Name = "Name"; p.Accessibility = Accessibility.Public; p.AddAttribute(CodeAnnotations.CompilerGenerated); }); + builder.IntroduceProperty(nameof(QualifiedNameTemplate), buildProperty: p => { p.Name = "QualifiedName"; p.Accessibility = Accessibility.Public; p.AddAttribute(CodeAnnotations.CompilerGenerated); }); + builder.IntroduceMethod(nameof(ToStringTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "ToString"; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + + builder.AddInitializer(nameof(NamesInitializerTemplate), InitializerKind.BeforeTypeConstructor); + + var listType = TypeFactory.GetNamedType(typeof(IReadOnlyList<>)).WithTypeArguments(target); + builder.IntroduceField(nameof(_all), IntroductionScope.Static, OverrideStrategy.Fail, f => + { + f.Accessibility = Accessibility.Private; + f.Type = listType; + f.Writeability = Writeability.ConstructorOnly; + f.InitializerExpression = ExpressionFactory.Parse($"new {ValueObjectTypes.SourceName(target)}[] {{ {string.Join(", ", members.Select(m => $"{ValueObjectTypes.SourceName(target)}.{m.Name}"))} }}"); + f.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceProperty(nameof(AllTemplate), buildProperty: p => + { + p.Name = "All"; + p.Type = listType; + p.Accessibility = Accessibility.Public; + p.IsStatic = true; + p.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + + builder.IntroduceMethod(nameof(TryParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Accessibility = Accessibility.Public; + m.Parameters[1].Type = target.ToNullable(); + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceMethod(nameof(ParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.Accessibility = Accessibility.Public; + m.ReturnType = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + + builder.IntroduceMethod(nameof(MatchTemplate), IntroductionScope.Instance, OverrideStrategy.Fail, m => + { + m.Name = "Match"; + m.Accessibility = Accessibility.Public; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + var func = m.Parameters[0].Type; + m.Parameters[0].Name = Camel(members[0].Name); + foreach (var member in members.Skip(1)) m.AddParameter(Camel(member.Name), func); + }); + + var converter = builder.IntroduceClass($"{target.Name}JsonConverter", buildType: t => + { + t.Accessibility = Accessibility.Public; + t.BaseType = TypeFactory.GetNamedType(typeof(JsonConverter<>)).WithTypeArguments(target); + t.IsStatic = false; + t.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + converter.IntroduceMethod(nameof(JsonReadTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Read"; m.ReturnType = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + converter.IntroduceMethod(nameof(JsonWriteTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Write"; m.Parameters[1].Type = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(JsonConverterAttribute), constructorArguments: [converter.Target])); + builder.IntroduceAttribute(AttributeConstruction.Create(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)).WithTypeArguments(target))); + builder.IntroduceAttribute(CodeAnnotations.CompilerGenerated); + } + + [CompileTime] + private static string Camel(string name) => char.ToLowerInvariant(name[0]) + name[1..]; +} diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs new file mode 100644 index 0000000..6e32c3d --- /dev/null +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -0,0 +1,21 @@ +using CodoMetis.TypeKit.ClosedSets; +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; + +namespace CodoMetis.TypeKit.Generators; + +/// What the fabric and the aspect know about closed sets: a class implementing IClosedSet<TSelf>, directly or through another interface. +[CompileTime] +internal static class ClosedSetTypes +{ + public static bool IsClosedSet(INamedType type) => + type.TypeKind == TypeKind.Class && Marker(type) is not null; + + /// The IClosedSet<TSelf> the type implements, or null. + public static INamedType? Marker(INamedType type) => + type.AllImplementedInterfaces.FirstOrDefault(i => i.IsGeneric && i.Definition.Equals(TypeFactory.GetNamedType(typeof(IClosedSet<>)))); + + /// The members: every public static readonly field of the set's own type, in declaration order. + public static IReadOnlyList Members(INamedType type) => + [.. type.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(type) && f.Writeability == Writeability.ConstructorOnly)]; +} diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 0cdc708..718cb87 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -182,6 +182,52 @@ public sealed partial record ApiKey : IValue `DataClassificationAttribute` on the value object's type makes the logging generator report LOGGEN035 and generate nothing for the method. +## Closed sets + +An `enum` names its members and nothing else: a `switch` over one is never proven complete (C# +only warns about a missing discard), a member carries no data, and the names are integers on the +wire unless a converter says otherwise. A closed set is a sealed class whose every instance is a +named member, declared as a `public static readonly` field, with constant data through a private +constructor where the members carry some: + +```csharp +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public sealed partial class Tier : IClosedSet +{ + public static readonly Tier Basic = new(1, null); + public static readonly Tier Premium = new(2, "Priority support."); + + private Tier(int rank, string? perk) { Rank = rank; Perk = perk; } + + public int Rank { get; } + public string? Perk { get; } +} +``` + +The generators add `Name` from the field's name, `QualifiedName` (`OrderStatus.Open`), `All` in +declaration order, `Match` with one required delegate per member named after it, `TryParse` and +`Parse` by name, `ToString()`, a JSON converter that writes the name and reads the member itself +back, and a private parameterless constructor where the type declares none: + +```csharp +var label = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); // every member, or it does not compile +var names = OrderStatus.All.Select(s => s.Name); // Open Shipped Cancelled +var same = OrderStatus.Parse("Shipped") == OrderStatus.Shipped; // True, members are singletons +var json = JsonSerializer.Serialize(Tier.Premium); // "Premium" +``` + +Members are singletons, so equality is reference equality, and a member added later fails every +`Match` until it is handled. `Parse` and the JSON converter refuse an unknown name with an +exception naming the set, never the input. The EF Core satellite maps a closed set to a text column +of the member's name, and the ASP.NET Core satellite describes it as a string enum of the names, +with nothing registered per set. + ## JSON The wire format is the wrapped value's, so an `OrderId` is a string and a `Quantity` a number: @@ -275,8 +321,14 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1010 | Declared in more than one `partial` part: Metalama 2026.1 writes the generated `ToString()` into every part, which does not compile. A part a source generator adds, such as a `[GeneratedRegex]` method's, does not count. | | CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members"), an explicit implementation of an interface they implement, a hand-written equality, a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | | CMTK1012 | Instance state besides the wrapped value: a field, an auto-property, a `required` member or a field-like event, declared or inherited. Compute it from `Value`, make it static, or wrap a type that holds all of it. | - -The analyzers that come with the base package add CMTK0001 to CMTK0009; see +| CMTK1013 | A closed set not declared `partial`. | +| CMTK1014 | A closed set not declared `sealed`: a set with subtypes is not closed. | +| CMTK1015 | A closed set with no member: a member is a `public static readonly` field of the set's own type. | +| CMTK1016 | A closed set with a public constructor, so an instance beside its members could be made. | +| CMTK1017 | A closed set with a public static member of its own type that is not a readonly field: a property, or a field that is not `readonly`. | +| CMTK1018 | A closed set that is generic or nested in a generic type. | + +The analyzers that come with the base package add CMTK0001 to CMTK0011; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). ## Reading the generated code diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs index 504d55e..6108c34 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs @@ -31,10 +31,16 @@ namespace CodoMetis.TypeKit.Generators; /// internal sealed class ValueObjectFabric : TransitiveProjectFabric { - public override void AmendProject(IProjectAmender amender) => + public override void AmendProject(IProjectAmender amender) + { amender.SelectTypes(includeNestedTypes: true) .Where(ValueObjectTypes.IsValueObject) .AddAspect(type => new ValueObjectAspect( CompanionClass.NameOwner(type), ValueObjectTypes.WrappedValueObjectRefusal(type))); + + amender.SelectTypes(includeNestedTypes: true) + .Where(ClosedSetTypes.IsClosedSet) + .AddAspect(_ => new ClosedSetAspect()); + } } diff --git a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs new file mode 100644 index 0000000..4fb8186 --- /dev/null +++ b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CodoMetis.TypeKit.ClosedSets; + +/// +/// Declares a closed set, a type whose every instance is a named member declared as a +/// public static readonly field of the type: +/// public sealed partial class OrderStatus : IClosedSet<OrderStatus> { public static readonly OrderStatus Open = new(); } +/// +/// +/// +/// CodoMetis.TypeKit.Generators generates everything else: from the field's name, +/// (OrderStatus.Open), in declaration order, +/// Match with one required delegate per member named after it, Parse and +/// by name, ToString(), a JSON converter that writes the name and reads +/// the member back, and a private parameterless constructor where the type declares none. A member +/// may carry constant data through a private constructor the type declares, as +/// new(FaultKind.NotFound, "The order does not exist."). +/// +/// +/// Unlike an enum, a closed set's switch is complete by construction: Match takes +/// a delegate per member, so a member added later fails every call site until it is handled. The +/// type is a sealed class, so members are singletons and equality is reference equality. +/// +/// +/// The type must be a sealed partial class (CMTK1013, CMTK1014) with at least one member (CMTK1015), +/// no public constructor (CMTK1016), and no other public static member of its own type (CMTK1017), +/// since every one of those would be a member the generators cannot see as one. +/// +/// +/// Without a reference to CodoMetis.TypeKit.Generators nothing is generated, and the analyzer +/// reports CMTK0002 on the declaration. +/// +/// +/// The set itself. +public interface IClosedSet where TSelf : class, IClosedSet +{ + /// The member's name: the name of the field that declares it. + string Name { get; } + + /// The set's name and the member's, OrderStatus.Open, so two sets that share a member name stay apart. + string QualifiedName { get; } + + /// Every member, in declaration order. + static abstract IReadOnlyList All { get; } + + /// The member of that name, if any. Names compare ordinally. + /// The name. + /// The member, or null. + /// Whether a member has that name. + static abstract bool TryParse(string? name, [MaybeNullWhen(false)] out TSelf member); +} diff --git a/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSetAttribute.cs b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSetAttribute.cs new file mode 100644 index 0000000..db93c13 --- /dev/null +++ b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSetAttribute.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using CodoMetis.TypeKit.ClosedSets; + +namespace CodoMetis.TypeKit.CompilerServices; + +/// +/// What the generators put on every closed set, so run-time code that holds only a +/// recognises one and reaches it typed, through , without reflection. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public abstract class GeneratedClosedSetAttribute : Attribute +{ + private protected GeneratedClosedSetAttribute() + { + } + + /// The set. + public abstract Type SetType { get; } + + /// The members' names, in declaration order. + public abstract IReadOnlyList Names { get; } + + /// Calls with the set as a type argument. + /// The visitor. + /// What the visitor makes. + /// What the visitor made. + public abstract TResult Accept(IClosedSetVisitor visitor); +} + +/// The closed-set attribute for . +/// The set. +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class GeneratedClosedSetAttribute : GeneratedClosedSetAttribute + where TSet : class, IClosedSet +{ + /// + public override Type SetType => typeof(TSet); + + /// + public override IReadOnlyList Names => field ??= [.. TSet.All.Select(member => member.Name)]; + + /// + public override TResult Accept(IClosedSetVisitor visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + return visitor.Visit(); + } +} + +/// What calls with the set as a type argument. +/// What the visitor makes. +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IClosedSetVisitor +{ + /// Called with the set. + /// The set. + /// What the visitor makes. + TResult Visit() where TSet : class, IClosedSet; +} diff --git a/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs new file mode 100644 index 0000000..b5f9fc5 --- /dev/null +++ b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; +using System.Text.Json; + +namespace CodoMetis.TypeKit.CompilerServices; + +/// What the generated closed-set members throw. Public only so generated code can call it; there is nothing to call by hand. +[EditorBrowsable(EditorBrowsableState.Never)] +public static class GeneratedClosedSets +{ + /// The refusal of Parse: the set has no member of that name. Names the set, never the input. + /// The set's name. + /// The exception to throw. + public static FormatException NoMember(string set) => + new($"{set} has no member of that name.{Refusals.ClosedSetMember}"); + + /// The refusal of the JSON converter: the set has no member of that name, or the token is not a string. Names the set, never the input. + /// The set's name. + /// The exception to throw. + public static JsonException NoMemberInJson(string set) => + new($"{set} has no member of that name.{Refusals.ClosedSetMember}"); +} diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 46af9cb..0230024 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -521,6 +521,13 @@ one. A string-backed value object would otherwise wrap the null it was given. **Instead:** where the value can be absent, declare the property or parameter as `ProductName?`. JSON then reads a `null` as `null`, and the value object is never asked to hold it. +### A closed set has no member of that name + +`OrderStatus.Parse("nope")` and a JSON `"nope"` for an `OrderStatus` property throw: a closed set's +members are its `public static readonly` fields, and a name that is none of them, compared +ordinally, is refused. The message names the set and never the input. `TryParse` answers `false` +instead, and `All` lists the names there are. + ### Materialize and CMTK0004 Not a run-time exception but a build error: `Materialize` rebuilds a value object without `Create`, @@ -532,6 +539,7 @@ stored. ## Where things are `Option`, `Result`, `OrderId.New()`, `AsText()`, `AsIdentifier()`, `Patch`, `[RequireCustomInitialization]`, `[RequireParameterlessConstructor]` and the type-parameter attributes `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` live in `CodoMetis.TypeKit`; -every value-object contract in `CodoMetis.TypeKit.ValueObjects`. What only the generated code and the +every value-object contract in `CodoMetis.TypeKit.ValueObjects`, and `IClosedSet` in `CodoMetis.TypeKit.ClosedSets` +(generated by [CodoMetis.TypeKit.Generators](https://www.nuget.org/packages/CodoMetis.TypeKit.Generators), which has the family's README section). What only the generated code and the satellites call is in `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense. Targets .NET 10. Source and issues: [github.com/CaffeinatedCoder/CodoMetis.TypeKit](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit). diff --git a/src/CodoMetis.TypeKit/Refusals.cs b/src/CodoMetis.TypeKit/Refusals.cs index c73d4ff..ec3873e 100644 --- a/src/CodoMetis.TypeKit/Refusals.cs +++ b/src/CodoMetis.TypeKit/Refusals.cs @@ -35,4 +35,7 @@ internal static class Refusals /// The wrapped type could not read JSON or text at all. public const string UnreadableInput = " See " + Readme + "a-value-object-could-not-read-the-input"; + + /// A closed set asked for a member it does not have, by name, in text or JSON. + public const string ClosedSetMember = " See " + Readme + "a-closed-set-has-no-member-of-that-name"; } diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs new file mode 100644 index 0000000..aa7b51e --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// A closed set in the document: a string enum of its members' names, as an enum is described. +public sealed class ClosedSetSchemaTests +{ + public sealed record Ticket(ProbeStatus Status, ProbeTier Tier); + + public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_closed_set_is_a_string_enum_of_its_members(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, + endpoints: app => app.MapPost("/tickets", int (Ticket ticket) => 0)); + + var status = document.Resolve(document.Property(nameof(Ticket), "status")); + + status["type"]!.GetValue().ShouldBe("string"); + status["enum"]!.AsArray().Select(node => node!.GetValue()).ShouldBe(["Open", "Shipped", "Cancelled"]); + status["properties"].ShouldBeNull(); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs new file mode 100644 index 0000000..8cdd202 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs @@ -0,0 +1,61 @@ +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// A closed set as a column: the member's name, read back as the member itself, with nothing registered per set. +public sealed class ClosedSetMappingTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public ClosedSetMappingTests() => _connection.Open(); + + public void Dispose() => _connection.Dispose(); + + private DbContextOptions Options => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options; + + [Fact] + public void A_closed_set_property_is_a_text_column_of_the_member_s_name() + { + using var db = new SetDb(Options); + + var status = db.Model.FindEntityType(typeof(Ticket))!.FindProperty(nameof(Ticket.Status)).ShouldNotBeNull(); + status.GetTypeMapping().Converter.ShouldNotBeNull().ProviderClrType.ShouldBe(typeof(string)); + status.GetColumnType().ShouldBe("TEXT"); + } + + [Fact] + public void A_member_round_trips_and_a_query_compares_by_name() + { + using (var db = new SetDb(Options)) + { + db.Database.EnsureCreated(); + db.Tickets.AddRange(new Ticket { Id = 1, Status = ProbeStatus.Open, Tier = ProbeTier.Premium }, new Ticket { Id = 2, Status = ProbeStatus.Shipped, Tier = ProbeTier.Basic }); + db.SaveChanges(); + } + + using (var db = new SetDb(Options)) + { + var shipped = db.Tickets.Single(ticket => ticket.Status == ProbeStatus.Shipped); + ReferenceEquals(shipped.Status, ProbeStatus.Shipped).ShouldBeTrue(); + ReferenceEquals(shipped.Tier, ProbeTier.Basic).ShouldBeTrue(); + + db.Tickets.Where(ticket => ticket.Tier == ProbeTier.Premium).ToQueryString().ShouldContain("'Premium'"); + } + } + + public sealed class Ticket + { + public int Id { get; set; } + + public ProbeStatus Status { get; set; } = ProbeStatus.Open; + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + } + + public sealed class SetDb(DbContextOptions options) : DbContext(options) + { + public DbSet Tickets => Set(); + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 79b2f17..df95d48 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -1,3 +1,4 @@ +using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace CodoMetis.TypeKit.Generators.Probes; @@ -185,3 +186,28 @@ public abstract record ProbeMaskingRecord /// What every derived record prints. public sealed override string ToString() => "***"; } + +/// A closed set with no data and no constructor: the generators add a private one. +public sealed partial class ProbeStatus : IClosedSet +{ + public static readonly ProbeStatus Open = new(); + public static readonly ProbeStatus Shipped = new(); + public static readonly ProbeStatus Cancelled = new(); +} + +/// A closed set with constant data per member, through a private constructor. +public sealed partial class ProbeTier : IClosedSet +{ + public static readonly ProbeTier Basic = new(1, null); + public static readonly ProbeTier Premium = new(2, "Priority support."); + + private ProbeTier(int rank, string? perk) + { + Rank = rank; + Perk = perk; + } + + public int Rank { get; } + + public string? Perk { get; } +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 66ecc78..b419889 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -104,6 +104,13 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1012", "WithRequiredMember")] [InlineData("CMTK1012", "WithInheritedState")] [InlineData("CMTK1012", "WithEvent")] + [InlineData("CMTK1013", "SetNotPartial")] + [InlineData("CMTK1014", "SetNotSealed")] + [InlineData("CMTK1015", "SetWithoutMembers")] + [InlineData("CMTK1016", "SetWithPublicConstructor")] + [InlineData("CMTK1017", "SetWithProperty")] + [InlineData("CMTK1017", "SetWithMutableField")] + [InlineData("CMTK1018", "SetGeneric")] public void A_declaration_that_cannot_be_generated_is_an_error(string id, string type) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.Contains($"'{type}"), $"{id} on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); @@ -284,12 +291,14 @@ public void A_static_constructor_is_allowed() => /// /// The declarations are refused with the errors above, never by an aspect that failed on them: /// an exception in an aspect or a compile error in the code it generated surfaces as LAMA0041 or - /// LAMA0611, which tells the user nothing about their declaration. + /// LAMA0611, which tells the user nothing about their declaration. CS0535 is the one compiler + /// error that accompanies a refused closed set: its interface has members the generators would + /// have implemented, and the CMTK error beside it says why they did not. /// [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", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012"], ignoreOrder: false, customMessage: consumer.Output); + .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); /// /// A generator error cannot carry a help link as an analyzer rule does (a Metalama 2026.1 @@ -303,7 +312,7 @@ public void Every_build_error_links_to_the_Build_errors_table() var errors = consumer.Errors.Where(error => error.Id.StartsWith("CMTK1", StringComparison.Ordinal)).ToList(); - errors.Select(error => error.Id).Distinct().Count().ShouldBe(13, $"Not every CMTK1000-CMTK1012 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); + errors.Select(error => error.Id).Distinct().Count().ShouldBe(19, $"Not every CMTK1000-CMTK1012 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); errors.Where(error => !error.Message.EndsWith($". See {link}", StringComparison.Ordinal)) .Select(error => $"{error.Id}: {error.Message}") .ShouldBeEmpty("These errors do not end with the link to the Build errors table."); @@ -368,10 +377,25 @@ public sealed partial class Consumer : IAsyncLifetime private const string Declarations = """ using CodoMetis.TypeKit; + using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace Consumer; + public sealed class SetNotPartial : IClosedSet { public static readonly SetNotPartial A = new(); } + + public partial class SetNotSealed : IClosedSet { public static readonly SetNotSealed A = new(); } + + public sealed partial class SetWithoutMembers : IClosedSet { } + + public sealed partial class SetWithPublicConstructor : IClosedSet { public static readonly SetWithPublicConstructor A = new(); public SetWithPublicConstructor() { } } + + public sealed partial class SetWithProperty : IClosedSet { public static readonly SetWithProperty A = new(); public static SetWithProperty B => A; } + + public sealed partial class SetWithMutableField : IClosedSet { public static readonly SetWithMutableField A = new(); public static SetWithMutableField B = A; } + + public sealed partial class SetGeneric : IClosedSet> { public static readonly SetGeneric A = new(); } + public readonly record struct NotPartial : IValue; public readonly partial struct NotRecord : IValue; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs new file mode 100644 index 0000000..85454a5 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.CompilerServices; +using CodoMetis.TypeKit.Generators.Probes; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// +/// The closed-set family, generated for and . +/// Measured first in spikes/ClosedSets; each claim there is a test here. +/// +public sealed class ClosedSetTests +{ + [Fact] + public void All_holds_the_members_themselves_in_declaration_order() + { + ProbeStatus.All.Select(member => member.Name).ShouldBe(["Open", "Shipped", "Cancelled"]); + ReferenceEquals(ProbeStatus.All[1], ProbeStatus.Shipped).ShouldBeTrue(); + ProbeTier.All.Select(member => member.Name).ShouldBe(["Basic", "Premium"]); + } + + [Fact] + public void Name_QualifiedName_and_ToString_come_from_the_field() + { + ProbeStatus.Open.Name.ShouldBe("Open"); + ProbeStatus.Cancelled.QualifiedName.ShouldBe("ProbeStatus.Cancelled"); + ProbeStatus.Shipped.ToString().ShouldBe("Shipped"); + $"{ProbeTier.Premium}".ShouldBe("Premium"); + } + + [Fact] + public void Constant_data_comes_through_the_declared_constructor() + { + ProbeTier.Premium.Rank.ShouldBe(2); + ProbeTier.Premium.Perk.ShouldBe("Priority support."); + ProbeTier.Basic.Perk.ShouldBeNull(); + } + + [Fact] + public void Match_takes_one_delegate_per_member_and_picks_the_branch() + { + ProbeStatus.Shipped.Match(open: () => "o", shipped: () => "s", cancelled: () => "c").ShouldBe("s"); + ProbeTier.Basic.Match(basic: () => 1, premium: () => 2).ShouldBe(1); + } + + [Fact] + public void TryParse_and_Parse_find_a_member_by_name_and_refuse_without_quoting() + { + ProbeStatus.TryParse("Shipped", out var shipped).ShouldBeTrue(); + ReferenceEquals(shipped, ProbeStatus.Shipped).ShouldBeTrue(); + ProbeStatus.TryParse("shipped", out _).ShouldBeFalse("names compare ordinally"); + ProbeStatus.TryParse(null, out _).ShouldBeFalse(); + + ReferenceEquals(ProbeStatus.Parse("Open"), ProbeStatus.Open).ShouldBeTrue(); + + var refusal = Should.Throw(() => ProbeStatus.Parse("Nope")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + refusal.Message.ShouldContain("#a-closed-set-has-no-member-of-that-name"); + } + + [Fact] + public void JSON_is_the_name_and_reads_the_member_itself_back() + { + var json = JsonSerializer.Serialize(new Holder(ProbeStatus.Shipped, ProbeTier.Premium)); + json.ShouldBe("""{"Status":"Shipped","Tier":"Premium"}"""); + + var back = JsonSerializer.Deserialize(json)!; + ReferenceEquals(back.Status, ProbeStatus.Shipped).ShouldBeTrue(); + ReferenceEquals(back.Tier, ProbeTier.Premium).ShouldBeTrue(); + + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"Status":"Nope","Tier":"Basic"}""")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + + Should.Throw(() => JsonSerializer.Deserialize("""{"Status":3,"Tier":"Basic"}""")).Message.ShouldContain("ProbeStatus"); + } + + [Fact] + public void Members_are_singletons_and_the_constructor_is_private() + { + (ProbeStatus.Open == ProbeStatus.Parse("Open")).ShouldBeTrue(); + typeof(ProbeStatus).GetConstructors().ShouldBeEmpty("no public constructor"); + typeof(ProbeStatus).IsSealed.ShouldBeTrue(); + } + + [Fact] + public void The_attribute_names_the_members_and_reaches_the_set_typed() + { + var attribute = typeof(ProbeTier).GetCustomAttributes(inherit: false).OfType().ShouldHaveSingleItem(); + + attribute.SetType.ShouldBe(typeof(ProbeTier)); + attribute.Names.ShouldBe(["Basic", "Premium"]); + attribute.Accept(new Counter()).ShouldBe(2); + } + + [Fact] + public void Generic_code_reaches_a_set_through_the_interface() + { + Names().ShouldBe("Open,Shipped,Cancelled"); + Names().ShouldBe("Basic,Premium"); + } + + private static string Names() where TSet : class, IClosedSet => string.Join(",", TSet.All.Select(member => member.Name)); + + private sealed class Counter : IClosedSetVisitor + { + public int Visit() where TSet : class, IClosedSet => TSet.All.Count; + } + + private sealed record Holder(ProbeStatus Status, ProbeTier Tier); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index fe8682c..4284169 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -1053,6 +1053,34 @@ ProbeSpan static op_Inequality(ProbeSpan, ProbeSpan) : Boolean extension static ProbeSpanExtensions.GetValue(ProbeSpan) : ProbeSpanText extension static ProbeSpanExtensions.ValueOrNull(ProbeSpan?) : ProbeSpanText +ProbeStatus + Match(Func, Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + implements IClosedSet + nested ProbeStatusJsonConverter : JsonConverter + static All : IReadOnlyList + static Parse(String) : ProbeStatus + static TryParse(String, out ProbeStatus) : Boolean +ProbeTier + Match(Func, Func) : TResult + Name : String + Perk : String + QualifiedName : String + Rank : Int32 + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + implements IClosedSet + nested ProbeTierJsonConverter : JsonConverter + static All : IReadOnlyList + static Parse(String) : ProbeTier + static TryParse(String, out ProbeTier) : Boolean ProbeTime CompareTo(Object) : Int32 CompareTo(ProbeTime) : Int32 diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs index be9e314..cc39a70 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs @@ -45,7 +45,7 @@ public void The_generated_surface_matches_the_snapshot() private static string Render(Assembly assembly) { var valueObjects = assembly.GetTypes() - .Where(type => type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValueObject<,>))) + .Where(type => type.GetInterfaces().Any(i => i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(IValueObject<,>) || i.GetGenericTypeDefinition() == typeof(CodoMetis.TypeKit.ClosedSets.IClosedSet<>)))) .OrderBy(type => type.FullName, StringComparer.Ordinal) .ToList(); diff --git a/test/CodoMetis.TypeKit.Samples/Generators/ClosedSetSamples.cs b/test/CodoMetis.TypeKit.Samples/Generators/ClosedSetSamples.cs new file mode 100644 index 0000000..913d81f --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Generators/ClosedSetSamples.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; + +namespace ReadmeSamples.Generators.ClosedSets; + +// sample: CodoMetis.TypeKit.Generators/closed-set +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public sealed partial class Tier : IClosedSet +{ + public static readonly Tier Basic = new(1, null); + public static readonly Tier Premium = new(2, "Priority support."); + + private Tier(int rank, string? perk) { Rank = rank; Perk = perk; } + + public int Rank { get; } + public string? Perk { get; } +} +// end sample + +public static class Using +{ + public static (string Label, string Names, bool Singleton, string Json) Run(OrderStatus status) + { + // sample: CodoMetis.TypeKit.Generators/closed-set-use + var label = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); // every member, or it does not compile + var names = OrderStatus.All.Select(s => s.Name); // Open Shipped Cancelled + var same = OrderStatus.Parse("Shipped") == OrderStatus.Shipped; // True, members are singletons + var json = JsonSerializer.Serialize(Tier.Premium); // "Premium" + // end sample + + return (label, string.Join(", ", names), same, json); + } +} + +public sealed class ClosedSetTests +{ + private const string Readme = "src/CodoMetis.TypeKit.Generators/README.md"; + + [Fact] + public void The_sample_answers_what_its_comments_state() + { + var (label, names, singleton, json) = Using.Run(OrderStatus.Shipped); + + label.ShouldBe("On its way"); + OrderStatus.All.Select(s => s.Name).ShouldBeShownIn(Readme, all => string.Join(" ", all)); + (OrderStatus.Parse("Shipped") == OrderStatus.Shipped).ShouldBeShownIn(Readme, b => b.ToString()); + JsonSerializer.Serialize(Tier.Premium).ShouldBeShownIn(Readme, j => j); + names.ShouldBe("Open, Shipped, Cancelled"); + singleton.ShouldBeTrue(); + json.ShouldBe("\"Premium\""); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index 3d63b42..f3244cf 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -41,6 +41,7 @@ public sealed partial class RefusalLinkTests private const string KnownGood = "FromKnownGood refused a value"; private const string Unreadable = "A value object could not read the input"; private const string NullToValueObject = "A null given to a value object"; + private const string ClosedSetMember = "A closed set has no member of that name"; private enum Fault { TooLong } @@ -48,6 +49,8 @@ private enum Fault { TooLong } private static readonly Dictionary Refusal)> Refusals = new() { + ["Closed set Parse of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember("OrderStatus")), + ["Closed set JSON of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson("OrderStatus")), ["Option written as JSON"] = (InJson, () => JsonSerializer.Serialize(Option.Some(1))), ["Option read from JSON"] = (InJson, () => JsonSerializer.Deserialize>("1")), ["Option? written as a JSON null"] = (InJson, () => JsonSerializer.Serialize?>(null)), From b514d10cbed6dab420654c7f8995779f0f5184ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 13:09:12 +0200 Subject: [PATCH 017/147] fix: closed sets that compiled and did less, and Patch.IfPresent clearing a struct to default Closed sets, from a generation review of 09d8b78: - A member initialized with another (`Legacy = Open`) renamed the real one: Name, JSON and the column said Legacy, All held Open twice. CMTK1019 now refuses a member not initialized with `new` where it is declared, and with nothing else. - A hand-written ToString() was replaced silently. It is kept as the seam; any other generated member written by hand, by signature, is CMTK1011 instead of a Metalama error naming no fix. Overloads of another signature are kept. - An instance the set's own code made beside its members had a null Name, written as JSON null and stored as a null column. CMTK1016 refuses any constructor that is not private, CMTK1020 a record (`with` makes a copy that is no member), and Name, ToString, Match, JSON and EF throw NotAMember for such an instance. - The generated TryParse lacked [NotNullWhen(true)], so a found member was CS8602 at the call site. - CMTK0002 now covers IClosedSet, as IClosedSet's docs claimed, and BuildOutcomeTests allows CS0535 only on the refused sets. - A JSON null is the serializer's to read, as for any class; the converter comment claiming a refusal is corrected and both behaviours are tested. Patch.IfPresent handed a clear over as default(T) for a struct: a zero, or an uninitialized value object. It is now two extensions, a class T receiving T? and a struct T receiving Nullable, so a clear is null. Seeded: each fix reverted fails its guard (14 failures across 7 seeds, then the TryParse attribute, the CS0535 guard, the trailing call, the record and the overload probe one by one). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +- docs/plan.md | 2 +- src/CodoMetis.TypeKit.Analyzers/README.md | 4 +- .../TypeKitSymbols.cs | 16 +- .../ValueObjectWithoutGeneratorsAnalyzer.cs | 11 +- .../AspectDiagnostics.cs | 8 +- .../ClosedSetAspect.Templates.cs | 29 +++- .../ClosedSetAspect.cs | 25 ++- .../ClosedSetTypes.cs | 152 ++++++++++++++++++ src/CodoMetis.TypeKit.Generators/README.md | 14 +- .../ValueObjectDeclaration.cs | 2 +- .../ClosedSets/IClosedSet.cs | 18 ++- .../CompilerServices/GeneratedClosedSets.cs | 10 ++ src/CodoMetis.TypeKit/Patch.cs | 53 ++++-- src/CodoMetis.TypeKit/README.md | 21 ++- src/CodoMetis.TypeKit/Refusals.cs | 3 + ...lueObjectWithoutGeneratorsAnalyzerTests.cs | 21 +++ .../ClosedSetMappingTests.cs | 18 +++ .../Probes.cs | 53 ++++++ .../BuildOutcomeTests.cs | 41 ++++- .../ClosedSetTests.cs | 78 +++++++++ .../GeneratedSurface.verified.txt | 44 +++++ .../NullDelegateTests.cs | 3 +- test/CodoMetis.TypeKit.Tests/PatchTests.cs | 19 +++ .../RefusalLinkTests.cs | 2 + 25 files changed, 611 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350d04a..564756c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,15 +29,21 @@ Semantic Versioning reserves for a major, listed under Changed as they land. generators add `Name`, `QualifiedName`, `All` in declaration order, `Match` with one required delegate per member, `TryParse` and `Parse` by name, `ToString()`, a JSON converter that writes the name and reads the member itself back, a private constructor where none is declared, and the attribute the - satellites read. CMTK1013 to CMTK1018 refuse a declaration that is not partial, not sealed, without - members, with a public constructor, with another public static member of its own type, or generic. + satellites read. CMTK1013 to CMTK1019 refuse a declaration that is not partial, not sealed, without + members, with a constructor that is not private, with another public static member of its own type, + generic, or with a member not initialized with `new` where it is declared; CMTK1020 refuses a + record. A hand-written `ToString()` is kept, and an overload of a generated method with another + signature; any other generated member written by hand is CMTK1011. An instance the set's + own code makes beside its members has no name, and reading it throws rather than write a null. + CMTK0002 covers a closed set without the generators. `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name; `CodoMetis.TypeKit.AspNetCore` describes it as a string enum. Nothing is registered per set. - **`CodoMetis.TypeKit`**: `Patch`, a field of a JSON Merge Patch (RFC 7396): absent, null or a value, where System.Text.Json alone cannot tell an absent property from a null one. `default` is absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never refuses a null; a value object inside a patch keeps its own rule. `Match`, `TryGetValue`, `IfValue`, - `IfPresent` and `ToOption` apply it without reflection. Writing an absent patch is refused, and + `IfPresent` and `ToOption` apply it without reflection; `IfPresent` hands a clear over as null, a + `T?` over a struct too, never as `default(T)`. Writing an absent patch is refused, and `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out. Under a source-generated context a primitive, a string, a `Guid`, a date, a `Uri` and a value object need no listing; a class or an enum does. Works under Native AOT: the converter is registered by the closed type's static diff --git a/docs/plan.md b/docs/plan.md index 3ed028f..c9b7b06 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -588,7 +588,7 @@ Each phase ends green, and its guards have been proven by seeding the defect 7. **The closed-set family** ✅ 2026-10-13, after [spikes/ClosedSets](../spikes/ClosedSets/README.md), which answered the compatibility question: a generated type with static members on Metalama 2026.1 and `net10.0`, nothing newer needed. `IClosedSet` in the base package, - `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1018, the + `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1020, the discovery attribute `GeneratedClosedSetAttribute` with a typed visitor, a text column in the EF satellite and a string enum in the OpenAPI satellite. The fault bits (a kind and a sentence per member) are step 8's, since the fault responses read them. diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 6601a36..3f18e35 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -13,7 +13,7 @@ appears to: an instance that passed no factory, or a value object that was never | Id | Severity | Reports | |---|---|---| | CMTK0001 | Error | `default`, `default(T)`, `new T()`, `new()` and `new T { }` of a value object, an `Option`, a `Result`, or a struct marked `[RequireCustomInitialization]`. Also through a type parameter whose constraints make it one of those. A default that is only compared (`id == default`, `id.Equals(default)`, `ThrowIfEqual(id, default)`, `Assert.NotEqual(default, id)`) is a guard and is not reported, nor is one assigned to an `out` parameter of a Try method that returns `bool`. | -| CMTK0002 | Error | A type implements `IValue` or `IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so nothing is generated for it: no field, no `Value`, no factory. | +| CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | | CMTK0003 | Warning | A `Result` or `Option` that a call returns, or a collection of them that the statement made, dropped by a statement, awaited or not, or by converting its `Task` to a plain `Task`, a method group's included. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core satellite. | | CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero is not reported. | @@ -112,6 +112,8 @@ CMTK0006 and CMTK0009 then treat it as they treat a value object. The generators apply through a transitive project fabric rather than an attribute on the interfaces, which keeps the base package free of Metalama. The price is that a project that sees the interfaces but not the generators compiles a value object with nothing in it, silently. This rule is that error. +A closed set fails to compile there anyway, on the interface members nobody implemented; the rule +says why. ```csharp public readonly partial record struct OrderId : IValue; // CMTK0002 without CodoMetis.TypeKit.Generators diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs index a5f06e2..5e44b95 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs @@ -46,6 +46,7 @@ internal sealed class TypeKitSymbols private readonly ImmutableArray _valueMarkers; private readonly ImmutableArray _validatedMarkers; + private readonly ImmutableArray _closedSetMarkers; private readonly ImmutableArray _requireCustomInitialization; private readonly ImmutableArray _requireParameterlessConstructor; private readonly ImmutableArray<(TypeArgumentConstraint Kind, INamedTypeSymbol Attribute)> _constraintAttributes; @@ -67,6 +68,7 @@ internal sealed class TypeKitSymbols private TypeKitSymbols( ImmutableArray valueMarkers, ImmutableArray validatedMarkers, + ImmutableArray closedSetMarkers, ImmutableArray requireCustomInitialization, ImmutableArray requireParameterlessConstructor, ImmutableArray<(TypeArgumentConstraint Kind, INamedTypeSymbol Attribute)> constraintAttributes, @@ -81,6 +83,7 @@ bool referencesGenerators _outcomes = outcomes; _valueMarkers = valueMarkers; _validatedMarkers = validatedMarkers; + _closedSetMarkers = closedSetMarkers; _requireCustomInitialization = requireCustomInitialization; _requireParameterlessConstructor = requireParameterlessConstructor; _constraintAttributes = constraintAttributes; @@ -108,6 +111,7 @@ bool referencesGenerators { var valueMarkers = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.ValueObjects.IValue`1"); var validatedMarkers = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.ValueObjects.IValidatedValue`3"); + var closedSetMarkers = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.ClosedSets.IClosedSet`1"); var attributes = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.RequireCustomInitializationAttribute"); var constructors = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.RequireParameterlessConstructorAttribute"); var constraints = ImmutableArray.CreateRange( @@ -122,12 +126,12 @@ from attribute in compilation.GetTypesByMetadataName($"CodoMetis.TypeKit.{kind}A var resultClasses = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.Result"); var optionClasses = compilation.GetTypesByMetadataName("CodoMetis.TypeKit.Option"); - if (valueMarkers.IsEmpty && validatedMarkers.IsEmpty && attributes.IsEmpty && constructors.IsEmpty && constraints.IsEmpty && materializers.IsEmpty) return null; + if (valueMarkers.IsEmpty && validatedMarkers.IsEmpty && closedSetMarkers.IsEmpty && attributes.IsEmpty && constructors.IsEmpty && constraints.IsEmpty && materializers.IsEmpty) return null; var referencesGenerators = compilation.ReferencedAssemblyNames.Any(identity => string.Equals(identity.Name, GeneratorsAssembly, StringComparison.OrdinalIgnoreCase)); - return new TypeKitSymbols(valueMarkers, validatedMarkers, attributes, constructors, constraints, materializers, converters, outcomes, resultClasses, optionClasses, referencesGenerators); + return new TypeKitSymbols(valueMarkers, validatedMarkers, closedSetMarkers, attributes, constructors, constraints, materializers, converters, outcomes, resultClasses, optionClasses, referencesGenerators); } /// @@ -360,6 +364,14 @@ public bool IsMaterialize(IMethodSymbol method, out ITypeSymbol? valueObject) return null; } + /// + /// The IClosedSet<TSelf> implements, directly or through + /// another interface, as the constructed interface. Apart from : a closed + /// set is generated by the same fabric, but no value-object rule applies to it. + /// + public INamedTypeSymbol? FindClosedSetMarker(ITypeSymbol type) => + InterfacesOf(type).FirstOrDefault(candidate => _closedSetMarkers.Contains(candidate.OriginalDefinition, SymbolEqualityComparer.Default)); + /// /// The interfaces of . A type parameter has none of its own: what it is /// guaranteed to implement comes from its constraints, which can be interfaces, a base class or diff --git a/src/CodoMetis.TypeKit.Analyzers/ValueObjectWithoutGeneratorsAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/ValueObjectWithoutGeneratorsAnalyzer.cs index 2d72e3c..19c7469 100644 --- a/src/CodoMetis.TypeKit.Analyzers/ValueObjectWithoutGeneratorsAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/ValueObjectWithoutGeneratorsAnalyzer.cs @@ -6,8 +6,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// -/// CMTK0002: a type implements IValue<T> or IValidatedValue<,,>, but the -/// project does not reference CodoMetis.TypeKit.Generators, so nothing is generated for it. +/// CMTK0002: a type implements IValue<T>, IValidatedValue<,,> or +/// IClosedSet<TSelf>, but the project does not reference CodoMetis.TypeKit.Generators, so +/// nothing is generated for it. /// /// /// The generators apply through a transitive fabric, not through an attribute on the interfaces, @@ -20,12 +21,12 @@ public sealed class ValueObjectWithoutGeneratorsAnalyzer : DiagnosticAnalyzer { private static readonly DiagnosticDescriptor Rule = new( DiagnosticIds.ValueObjectWithoutGenerators, - title: "Value object is not generated", + title: "Value object or closed set is not generated", messageFormat: "'{0}' implements {1}, but this project does not reference CodoMetis.TypeKit.Generators, so nothing is generated for it. Reference the CodoMetis.TypeKit.Generators package.", category: "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "Value objects are generated at compile time by CodoMetis.TypeKit.Generators. Without it, a type that implements IValue or IValidatedValue<,,> compiles, but has no field, no Value and no factory.", + description: "Value objects and closed sets are generated at compile time by CodoMetis.TypeKit.Generators. Without it, a type that implements IValue or IValidatedValue<,,> compiles, but has no field, no Value and no factory, and one that implements IClosedSet fails with compiler errors about the members it does not implement.", helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.ValueObjectWithoutGenerators) ); @@ -53,7 +54,7 @@ private static void AnalyzeType(SymbolAnalysisContext context, TypeKitSymbols sy // Only types the generators would pick up: an interface or abstract type is never generated. if (type.TypeKind is not (TypeKind.Struct or TypeKind.Class) || type.IsAbstract || type.IsStatic) return; - if (symbols.FindMarker(type, out _) is not { } marker) return; + if ((symbols.FindMarker(type, out _) ?? symbols.FindClosedSetMarker(type)) is not { } marker) return; // A partial declared in several files is reported where it can be: in the user's file, or in // the markup a Razor component's generated part maps to. diff --git a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs index 5e6f081..655a62c 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs @@ -67,11 +67,17 @@ internal static class AspectDiagnostics new("CMTK1015", Severity.Error, "'{0}' declares no member, so it is not generated: a member is a public static readonly field of the set's own type." + SeeBuildErrors); public static readonly DiagnosticDefinition ClosedSetPublicConstructor = - new("CMTK1016", Severity.Error, "'{0}' has a public constructor, so it is not generated: an instance beside its members could be made. Make the constructor private." + SeeBuildErrors); + new("CMTK1016", Severity.Error, "'{0}' has a constructor that is not private, so it is not generated: an instance beside its members could be made. Make the constructor private." + SeeBuildErrors); public static readonly DiagnosticDefinition<(INamedType, string, string)> ClosedSetMemberNotAField = new("CMTK1017", Severity.Error, "'{0}.{1}' is {2} of the set's own type, so '{0}' is not generated: a member is a public static readonly field, and nothing else of that type is public and static." + SeeBuildErrors); public static readonly DiagnosticDefinition ClosedSetGeneric = new("CMTK1018", Severity.Error, "'{0}' is generic or nested in a generic type, so it is not generated: a closed set's members are named instances, which a type parameter cannot have." + SeeBuildErrors); + + public static readonly DiagnosticDefinition<(INamedType, string)> ClosedSetMemberNotCreated = + new("CMTK1019", Severity.Error, "'{0}.{1}' is not initialized with new, so '{0}' is not generated: each member is its own instance, created where it is declared. A member initialized with another would give that instance two names, and one left null would have none." + SeeBuildErrors); + + public static readonly DiagnosticDefinition ClosedSetRecord = + new("CMTK1020", Severity.Error, "'{0}' is a record, so it is not generated: 'with' copies a member into an instance that is none, and a record compares by value where a closed set's members are singletons. Declare it a sealed partial class." + SeeBuildErrors); } diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs index 8cd0902..2bd2114 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Metalama.Framework.Aspects; @@ -15,20 +16,29 @@ public void PrivateConstructorTemplate() { } + /// + /// An instance the set's own code made beside its members has no name, and neither has a member + /// read before the names initializer ran: refused, since a null name is written as JSON null and + /// stored as a null column, and read back as nothing. + /// [Template] public string NameTemplate { - get => meta.This._name; + get + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return meta.This._name ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NotAMember(args.Set.Name); + } } [Template] public string QualifiedNameTemplate { - get => meta.Target.Type.Name + "." + meta.This._name; + get => meta.Target.Type.Name + "." + meta.This.Name; } [Template] - public string ToStringTemplate() => meta.This._name; + public string ToStringTemplate() => meta.This.Name; /// After the static field initializers, before any static constructor body: each member learns its name. [Template] @@ -48,14 +58,14 @@ public static dynamic? AllTemplate } [Template] - public static bool TryParseTemplate(string? name, out dynamic? member) + public static bool TryParseTemplate([NotNullWhen(true)] string? name, [NotNullWhen(true)] out dynamic? member) { var args = (ClosedSetArguments)meta.Tags.Source!; foreach (var candidate in args.Members) { if (name == candidate.Name) { - member = candidate.Value; + member = candidate.Value!; return true; } } @@ -88,10 +98,15 @@ public TResult MatchTemplate(Func first) index++; } - throw new InvalidOperationException(args.Set.Name + ": not a member of the set."); + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NotAMember(args.Set.Name); } - /// A naming the set, never the input; a JSON null is the serializer's own refusal. + /// + /// A naming the set, never the input. A JSON null never reaches this: a + /// set is a class, so the serializer reads null as null, and its RespectNullableAnnotations + /// refuses it for a property that is not nullable. Handling null here would refuse it for an + /// OrderStatus? property too, which the converter cannot tell apart. + /// [Template] public dynamic? JsonReadTemplate(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index 29f19cc..298054b 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -20,13 +20,15 @@ internal sealed class ClosedSetArguments /// /// Generates a closed set: Name, QualifiedName, All, Match, TryParse, -/// Parse, ToString(), the JSON converter, the discovery attribute, and a private -/// constructor where the type declares none. Applied by . +/// Parse, ToString() where none is written by hand, the JSON converter, the discovery +/// attribute, and a private constructor where the type declares none. Applied by . /// /// /// /// A member's name is assigned by an initializer that runs after the static field initializers and -/// before any static constructor body, so the declaration needs no nameof. Match's +/// before any static constructor body, so the declaration needs no nameof. Each member is its +/// own instance (CMTK1019), so no name overwrites another; an instance without one is refused where its +/// name is read. Match's /// parameters are added per member; the template declares one Func<TResult> parameter, /// whose bound type the others copy, since Metalama 2026.1 cannot build a generic instance over the /// method builder's own type parameter (spikes/ClosedSets). @@ -42,15 +44,27 @@ public override void BuildAspect(IAspectBuilder builder) if (!target.IsPartial) problems.Add(ClosedSetNotPartial.WithArguments(target)); if (!target.IsSealed) problems.Add(ClosedSetNotSealed.WithArguments(target)); + if (target.IsRecord) problems.Add(ClosedSetRecord.WithArguments(target)); if (target.IsGeneric || target.DeclaringType is { IsGeneric: true }) problems.Add(ClosedSetGeneric.WithArguments(target)); - if (target.Constructors.Any(c => !c.IsImplicitlyDeclared && c.Accessibility == Accessibility.Public)) problems.Add(ClosedSetPublicConstructor.WithArguments(target)); + if (target.Constructors.Any(c => !c.IsImplicitlyDeclared && c.Accessibility != Accessibility.Private)) problems.Add(ClosedSetPublicConstructor.WithArguments(target)); if (members.Count == 0) problems.Add(ClosedSetNoMembers.WithArguments(target)); + foreach (var member in members.Where(member => !ClosedSetTypes.IsCreatedWhereDeclared(member, target))) + problems.Add(ClosedSetMemberNotCreated.WithArguments((target, member.Name))); + foreach (var field in target.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(target) && f.Writeability != Writeability.ConstructorOnly)) problems.Add(ClosedSetMemberNotAField.WithArguments((target, field.Name, "a static field that is not readonly"))); foreach (var property in target.Properties.Where(p => p.IsStatic && p.Accessibility == Accessibility.Public && p.Type.Equals(target))) problems.Add(ClosedSetMemberNotAField.WithArguments((target, property.Name, "a static property"))); + // 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.Count); + if (declaredByHand.Count > 0) + problems.Add(HandWrittenGeneratedMember.WithArguments((target, string.Join(", ", declaredByHand), + (declaredByHand.Count == 1 ? "the generators introduce it themselves; remove it" : "the generators introduce these themselves; remove them") + + ". The generated member a closed set may declare instead is ToString()"))); + if (problems.Count > 0) { foreach (var problem in problems) builder.Diagnostics.Report(problem); @@ -79,7 +93,8 @@ public override void BuildAspect(IAspectBuilder builder) }); builder.IntroduceProperty(nameof(NameTemplate), buildProperty: p => { p.Name = "Name"; p.Accessibility = Accessibility.Public; p.AddAttribute(CodeAnnotations.CompilerGenerated); }); builder.IntroduceProperty(nameof(QualifiedNameTemplate), buildProperty: p => { p.Name = "QualifiedName"; p.Accessibility = Accessibility.Public; p.AddAttribute(CodeAnnotations.CompilerGenerated); }); - builder.IntroduceMethod(nameof(ToStringTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "ToString"; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + if (!ClosedSetTypes.DeclaresToString(target)) + builder.IntroduceMethod(nameof(ToStringTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "ToString"; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); builder.AddInitializer(nameof(NamesInitializerTemplate), InitializerKind.BeforeTypeConstructor); diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index 6e32c3d..b4bb96d 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -18,4 +18,156 @@ public static bool IsClosedSet(INamedType type) => /// The members: every public static readonly field of the set's own type, in declaration order. public static IReadOnlyList Members(INamedType type) => [.. type.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(type) && f.Writeability == Writeability.ConstructorOnly)]; + + /// + /// Whether is initialized with new of the set where it is declared, + /// and with nothing else: new(), new(1, null), new OrderStatus(), each with an + /// optional object initializer. Not = Open, which makes one instance two members whose names + /// overwrite each other, nor new OrderStatus().Canonical() or any other expression that only + /// starts with a creation, nor a call, nor no initializer at all, which leaves the member null until + /// a static constructor runs. + /// + /// + /// Read from the initializer's source text, since the code model exposes no syntax kind. The type + /// after new is compared by its last name; a using alias of the set is refused, which + /// is loud and names the member. + /// + public static bool IsCreatedWhereDeclared(IField member, INamedType set) + { + if (member.InitializerExpression is not ISourceExpression source) return false; + + var text = WithoutComments(source.AsString).Trim(); + if (text.Length <= 3 || !text.StartsWith("new", StringComparison.Ordinal) || IsIdentifierPart(text[3])) return false; + + var at = Skip(text, 3); + if (at < text.Length && text[at] != '(' && text[at] != '{') + { + var nameStart = at; + while (at < text.Length && (IsIdentifierPart(text[at]) || text[at] is '.' or ':' or '@')) at++; + + var typeName = text.Substring(nameStart, at - nameStart); + if (typeName.Substring(typeName.LastIndexOfAny(['.', ':']) + 1).TrimStart('@') != set.Name) return false; + + at = Skip(text, at); + } + + // An argument list, an object initializer, or both in that order, and then the end of the text. + var groups = 0; + if (at < text.Length && text[at] == '(') { at = Skip(text, AfterGroup(text, at)); groups++; } + if (at < text.Length && text[at] == '{') { at = Skip(text, AfterGroup(text, at)); groups++; } + + return groups > 0 && at == text.Length; + } + + private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private static int Skip(string text, int at) + { + while (at < text.Length && char.IsWhiteSpace(text[at])) at++; + return at; + } + + /// The index after the bracket that closes the one at , past nested brackets and literals; the length when it never closes. + private static int AfterGroup(string text, int open) + { + var depth = 0; + for (var at = open; at < text.Length; at++) + { + switch (text[at]) + { + case '(' or '{' or '[': + depth++; + break; + case ')' or '}' or ']': + if (--depth == 0) return at + 1; + break; + case '"' or '\'': + at = AfterLiteral(text, at); + break; + } + } + + return text.Length; + } + + /// The index of the quote that ends the string or character literal starting at ; a verbatim string doubles its quotes. + private static int AfterLiteral(string text, int quote) + { + var verbatim = text[quote] == '"' && quote > 0 && (text[quote - 1] == '@' || (quote > 1 && text[quote - 2] == '@')); + for (var at = quote + 1; at < text.Length; at++) + { + if (!verbatim && text[at] == '\\') { at++; continue; } + if (text[at] != text[quote]) continue; + if (verbatim && at + 1 < text.Length && text[at + 1] == '"') { at++; continue; } + return at; + } + + return text.Length; + } + + /// The text with its // and /* */ comments blanked, literals left as they are. + private static string WithoutComments(string text) + { + var chars = text.ToCharArray(); + for (var at = 0; at < chars.Length; at++) + { + if (chars[at] is '"' or '\'') + { + at = AfterLiteral(text, at); + } + else if (chars[at] == '/' && at + 1 < chars.Length && chars[at + 1] is '/' or '*') + { + var block = chars[at + 1] == '*'; + var end = block ? text.IndexOf("*/", at + 2, StringComparison.Ordinal) : text.IndexOf('\n', at); + end = end < 0 ? chars.Length : block ? end + 2 : end; + for (var blank = at; blank < end; blank++) chars[blank] = ' '; + at = end - 1; + } + } + + return new string(chars); + } + + /// + /// The members the generators introduce, written by hand: a property, field or nested type of a + /// generated name, a method with a generated signature, or an explicit implementation of + /// IClosedSet<TSelf>. Each failed the aspect with a Metalama error naming no fix. An + /// overload of another signature is the set's own and is kept, as is ToString(), the seam. + /// + public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, int memberCount) + { + var text = TypeFactory.GetType(SpecialType.String); + + return + [ + .. ValueObjectDeclaration.MembersNamed(set, methods: false, "Name", "QualifiedName", "All", "_name", "_all", $"{set.Name}JsonConverter"), + .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && SameParameters(method, text)).Select(ValueObjectDeclaration.Describe), + .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && SameParameters(method, text, set)).Select(ValueObjectDeclaration.Describe), + .. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, memberCount)).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), + ]; + } + + private static bool IsOwn(IMethod method) => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false }; + + /// Parameter types equal, nullability aside: Metalama refuses to introduce a method beside one of the same signature. + private static bool SameParameters(IMethod method, params IType[] types) => + method.Parameters.Count == types.Length + && method.Parameters.Select(parameter => parameter.Type.ToNonNullable()).Zip(types, (declared, expected) => declared.Equals(expected.ToNonNullable())).All(same => same); + + /// The generated Match<TResult>: one Func<TResult> per member. + private static bool IsGeneratedMatch(IMethod method, int memberCount) => + method is { Name: "Match", TypeParameters.Count: 1 } + && method.Parameters.Count == memberCount + && method.Parameters.All(parameter => parameter.Type is INamedType { IsGeneric: true } func + && 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 }); + + private static bool OfClosedSet(IMember implemented) => + implemented.DeclaringType.Definition.Equals(TypeFactory.GetNamedType(typeof(IClosedSet<>))); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 718cb87..f15f58b 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -228,6 +228,14 @@ exception naming the set, never the input. The EF Core satellite maps a closed s of the member's name, and the ASP.NET Core satellite describes it as a string enum of the names, with nothing registered per set. +Each member is initialized with `new` where it is declared (CMTK1019), and the constructors are +private (CMTK1016); a record is refused (CMTK1020). A `ToString()` written by hand is kept, and the +name is still what JSON, parsing and the column use; so is an overload of a generated method with +another signature, such as `Parse(int rank)`. Any other generated member written by hand is CMTK1011. An instance the set's +own code creates beside its members has no name, so reading its name throws rather than write a +null. A JSON `null` reads as `null`, as for any class, unless the serializer's +`RespectNullableAnnotations` refuses it. + ## JSON The wire format is the wrapped value's, so an `OrderId` is a string and a `Quantity` a number: @@ -319,14 +327,16 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | | CMTK1009 | A hand-written instance constructor, including a positional record's parameter list such as `OrderId(Guid Value)`. The constructor and `Value` are generated; a static constructor is fine. | | CMTK1010 | Declared in more than one `partial` part: Metalama 2026.1 writes the generated `ToString()` into every part, which does not compile. A part a source generator adds, such as a `[GeneratedRegex]` method's, does not count. | -| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members"), an explicit implementation of an interface they implement, a hand-written equality, a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | +| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members", and for a closed set every generated member but `ToString()`, by signature), an explicit implementation of an interface they implement, a hand-written equality, a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | | CMTK1012 | Instance state besides the wrapped value: a field, an auto-property, a `required` member or a field-like event, declared or inherited. Compute it from `Value`, make it static, or wrap a type that holds all of it. | | CMTK1013 | A closed set not declared `partial`. | | CMTK1014 | A closed set not declared `sealed`: a set with subtypes is not closed. | | CMTK1015 | A closed set with no member: a member is a `public static readonly` field of the set's own type. | -| CMTK1016 | A closed set with a public constructor, so an instance beside its members could be made. | +| CMTK1016 | A closed set with a constructor that is not private, so an instance beside its members could be made. | | CMTK1017 | A closed set with a public static member of its own type that is not a readonly field: a property, or a field that is not `readonly`. | | CMTK1018 | A closed set that is generic or nested in a generic type. | +| CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | +| CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | The analyzers that come with the base package add CMTK0001 to CMTK0011; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 9dd772c..ab69d10 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -475,7 +475,7 @@ private static bool IsHandWritten(IMember member) => /// Non-implicit members of with one of , of any kind. private static IEnumerable MembersNamed(INamedType target, params string[] names) => MembersNamed(target, methods: true, names); - private static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) + internal static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) { foreach (var field in target.Fields) if (!field.IsImplicitlyDeclared && Enumerable.Contains(names, field.Name)) yield return field.ToDisplayString(); diff --git a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs index 4fb8186..9194561 100644 --- a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs +++ b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs @@ -24,8 +24,19 @@ namespace CodoMetis.TypeKit.ClosedSets; /// /// /// The type must be a sealed partial class (CMTK1013, CMTK1014) with at least one member (CMTK1015), -/// no public constructor (CMTK1016), and no other public static member of its own type (CMTK1017), -/// since every one of those would be a member the generators cannot see as one. +/// only private constructors (CMTK1016), no other public static member of its own type (CMTK1017), +/// and not a record (CMTK1020), since every one of those would make an instance the generators cannot +/// see as a member. Each member is initialized with new where it is declared, and with nothing +/// else (CMTK1019): a member initialized with another member would give that instance two names. A +/// ToString() written by hand is kept, as is an overload of a generated method with another +/// signature; any other member the generators introduce, written by hand, is CMTK1011. +/// +/// +/// An instance the set's own code creates beside its members has no name: , the +/// generated ToString(), Match, JSON and the EF Core column throw for it rather than carry +/// a null. +/// A JSON null reads as , as for any class; the serializer's +/// RespectNullableAnnotations refuses it for a property that is not nullable. /// /// /// Without a reference to CodoMetis.TypeKit.Generators nothing is generated, and the analyzer @@ -36,6 +47,7 @@ namespace CodoMetis.TypeKit.ClosedSets; public interface IClosedSet where TSelf : class, IClosedSet { /// The member's name: the name of the field that declares it. + /// The instance is not one of the set's members. string Name { get; } /// The set's name and the member's, OrderStatus.Open, so two sets that share a member name stay apart. @@ -48,5 +60,5 @@ public interface IClosedSet where TSelf : class, IClosedSet /// The name. /// The member, or null. /// Whether a member has that name. - static abstract bool TryParse(string? name, [MaybeNullWhen(false)] out TSelf member); + static abstract bool TryParse([NotNullWhen(true)] string? name, [MaybeNullWhen(false)] out TSelf member); } diff --git a/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs index b5f9fc5..230648d 100644 --- a/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs +++ b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs @@ -18,4 +18,14 @@ public static FormatException NoMember(string set) => /// The exception to throw. public static JsonException NoMemberInJson(string set) => new($"{set} has no member of that name.{Refusals.ClosedSetMember}"); + + /// + /// The refusal of Name, ToString() and Match on an instance the set's own code + /// created beside its members, or on a member read before the set has named it: it has no name, and + /// a null written as JSON or stored in a column would read back as nothing. + /// + /// The set's name. + /// The exception to throw. + public static InvalidOperationException NotAMember(string set) => + new($"{set}: this instance is not a member of the set, so it has no name.{Refusals.ClosedSetNotAMember}"); } diff --git a/src/CodoMetis.TypeKit/Patch.cs b/src/CodoMetis.TypeKit/Patch.cs index 7a7440d..fc23689 100644 --- a/src/CodoMetis.TypeKit/Patch.cs +++ b/src/CodoMetis.TypeKit/Patch.cs @@ -54,7 +54,8 @@ public enum PatchState /// /// Applied without reflection: patch.Name.IfValue(name => order.Name = name) for a field a /// clear cannot reach, patch.Nickname.IfPresent(nickname => order.Nickname = nickname) for one -/// it can. ToString() never prints the value. +/// it can, which receives a T?: null for a clear, over a struct as over a class. ToString() +/// never prints the value. /// /// /// The type of the value. @@ -120,21 +121,11 @@ public void IfValue(Action action) if (_state == PatchState.Value) action(_value!); } - /// Runs with the value, or with null when the field was null, for a target a clear can reach. An absent field runs nothing. - /// What to do with the value or the clear. - /// is null. - public void IfPresent(Action action) - { - ArgumentNullException.ThrowIfNull(action); - - if (_state != PatchState.Absent) action(_state == PatchState.Value ? _value : default); - } - /// The state only, never the value. public override string ToString() => $"Patch {{ State = {_state} }}"; } -/// The three states of a , and ToOption(). +/// The three states of a , IfPresent and ToOption(). public static class Patch { /// A field that was not mentioned: the default. @@ -153,6 +144,44 @@ public static class Patch /// A patch holding the value, or a null patch. public static Patch Value(T? value) => value is null ? Null() : new(value, PatchState.Value); + /// The patch. + /// The type of the value, a class. + extension(Patch patch) where T : class? + { + /// Runs with the value, or with null when the field was null, for a target a clear can reach. An absent field runs nothing. + /// What to do with the value or the clear. + /// is null. + public void IfPresent(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (patch.State == PatchState.Absent) return; + + action(patch.TryGetValue(out var value) ? value : null); + } + } + + /// The patch. + /// The type of the value, a struct. + extension(Patch patch) where T : struct + { + /// + /// Runs with the value, or with null when the field was null, for a + /// target a clear can reach: a T?, so a clear is never default(T), a zero or an + /// uninitialized value object. An absent field runs nothing. + /// + /// What to do with the value or the clear. + /// is null. + public void IfPresent(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (patch.State == PatchState.Absent) return; + + action(patch.TryGetValue(out var value) ? value : null); + } + } + /// The patch. /// The type of the value. extension(Patch patch) where T : notnull diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 0230024..75529a7 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -386,8 +386,8 @@ They arrive with this package, so whoever can see the interfaces gets the guard: - **CMTK0001**: `default(OrderId)`, `new OrderId()`, `new()` and `default` of a value object, an `Option`, a `Result` or a `[RequireCustomInitialization]` struct. Such an instance passed no factory. -- **CMTK0002**: a type implements `IValue` or `IValidatedValue<,,>` but the project does not - reference `CodoMetis.TypeKit.Generators`, so nothing would be generated for it. +- **CMTK0002**: a type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet` but the + project does not reference `CodoMetis.TypeKit.Generators`, so nothing would be generated for it. - **CMTK0003** (warning): a `Result` or `Option` that a call returns, dropped. `_ = …` says it is meant. - **CMTK0004**: `Materialize`, which rebuilds a value object without its rules, called anywhere but @@ -528,6 +528,23 @@ members are its `public static readonly` fields, and a name that is none of them ordinally, is refused. The message names the set and never the input. `TryParse` answers `false` instead, and `All` lists the names there are. +### An instance that is not a member of its set + +**Thrown:** `InvalidOperationException: OrderStatus: this instance is not a member of the set, so it +has no name.` from `Name`, `QualifiedName`, the generated `ToString()`, `Match`, the JSON converter +and the EF Core column. + +**Why:** a closed set's constructors are private, but the set's own code can still call one outside +a member's declaration, in a static method or a private field. That instance has no name, and +written as JSON or stored in a column it would come back as nothing. A member's initializer or +constructor, or any other static field initializer of the set, that reads a member's name sees the +same thing, since the names are given after every static field is initialized; there the exception +surfaces as a `TypeInitializationException`, and the set cannot be used for the rest of the process. + +**Instead:** return one of the members, `OrderStatus.Open`, wherever the set's code produces an +instance, keep data a member needs in its own constructor's arguments, and read a name in a property +or method rather than a static field initializer. + ### Materialize and CMTK0004 Not a run-time exception but a build error: `Materialize` rebuilds a value object without `Create`, diff --git a/src/CodoMetis.TypeKit/Refusals.cs b/src/CodoMetis.TypeKit/Refusals.cs index ec3873e..dcc83be 100644 --- a/src/CodoMetis.TypeKit/Refusals.cs +++ b/src/CodoMetis.TypeKit/Refusals.cs @@ -38,4 +38,7 @@ internal static class Refusals /// A closed set asked for a member it does not have, by name, in text or JSON. public const string ClosedSetMember = " See " + Readme + "a-closed-set-has-no-member-of-that-name"; + + /// A closed set's instance that is not one of its members, asked for its name. + public const string ClosedSetNotAMember = " See " + Readme + "an-instance-that-is-not-a-member-of-its-set"; } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs index f9e1b83..ba3dfd4 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/ValueObjectWithoutGeneratorsAnalyzerTests.cs @@ -80,6 +80,27 @@ public Task A_marker_reached_through_another_interface_reports() => Test("public readonly partial record struct {|#0:CustomerId|} : Subjects.IIdentifier;", Cmtk0002("CustomerId", "IValue")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A closed set is generated by the same fabric. Its members are written out here only so the + /// compilation has no CS0535 beside the rule; without them the rule says why those errors appear. + /// + [Fact] + public Task A_closed_set_without_the_generators_reports() => + Test( + """ + public sealed partial class {|#0:OrderStatus|} : CodoMetis.TypeKit.ClosedSets.IClosedSet + { + public static readonly OrderStatus Open = new(); + + public string Name => "Open"; + public string QualifiedName => "OrderStatus.Open"; + public static System.Collections.Generic.IReadOnlyList All => [Open]; + public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out OrderStatus member) { member = Open; return name == "Open"; } + } + """, + Cmtk0002("OrderStatus", "IClosedSet")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_partial_type_reports_once() => Test( diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs index 8cdd202..747fa81 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs @@ -45,6 +45,22 @@ public void A_member_round_trips_and_a_query_compares_by_name() } } + /// + /// An instance the set's own code made beside its members has no name. Before, a nullable column + /// stored its null name and read it back as no value at all. + /// + [Fact] + public void An_instance_that_is_not_a_member_is_never_stored() + { + using var db = new SetDb(Options); + db.Database.EnsureCreated(); + db.Tickets.Add(new Ticket { Id = 3, Level = ProbeLevel.Unlisted() }); + + var thrown = Should.Throw(() => db.SaveChanges()); + var cause = thrown as InvalidOperationException ?? thrown.InnerException.ShouldBeOfType(); + cause.Message.ShouldEndWith("#an-instance-that-is-not-a-member-of-its-set"); + } + public sealed class Ticket { public int Id { get; set; } @@ -52,6 +68,8 @@ public sealed class Ticket public ProbeStatus Status { get; set; } = ProbeStatus.Open; public ProbeTier Tier { get; set; } = ProbeTier.Basic; + + public ProbeLevel? Level { get; set; } } public sealed class SetDb(DbContextOptions options) : DbContext(options) diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index df95d48..7c658ea 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -211,3 +211,56 @@ private ProbeTier(int rank, string? perk) public string? Perk { get; } } + +/// A closed set with a hand-written ToString(), the one seam: kept, while the name stays what JSON and parsing use. +public sealed partial class ProbeLabelled : IClosedSet +{ + public static readonly ProbeLabelled Low = new("low priority"); + public static readonly ProbeLabelled High = new("high priority"); + + private readonly string _label; + + private ProbeLabelled(string label) => _label = label; + + /// The label, not the name. + public override string ToString() => _label; +} + +/// A closed set whose own code makes an instance beside its members, which therefore has no name. +public sealed partial class ProbeLevel : IClosedSet +{ + public static readonly ProbeLevel Low = new(); + public static readonly ProbeLevel High = new(); + + /// Not a member: the private constructor is still the set's own to call. + public static ProbeLevel Unlisted() => new(); +} + +/// +/// A closed set whose members are created in every form CMTK1019 accepts, with a comment, a qualified +/// name, an object initializer and a literal holding brackets, and which declares overloads of the +/// generated Parse and Match with other signatures: its own, kept. +/// +public sealed partial class ProbePriority : IClosedSet +{ + public static readonly ProbePriority Low = new(/* the lowest (1) */ 1); + public static readonly ProbePriority Normal = new global::CodoMetis.TypeKit.Generators.Probes.ProbePriority(2) { Label = "normal (the default)" }; + public static readonly ProbePriority High = new(3) { Label = "high" }; + + private ProbePriority(int rank) => Rank = rank; + + public int Rank { get; } + + public string? Label { get; private init; } + + /// The member of that rank: another signature than the generated Parse(string). + public static ProbePriority Parse(int rank) => rank switch { 1 => Low, 2 => Normal, _ => High }; + + /// A switch that answers nothing: another signature than the generated Match<TResult>. + public void Match(Action low, Action normal, Action high) + { + if (ReferenceEquals(this, Low)) low(); + else if (ReferenceEquals(this, Normal)) normal(); + else high(); + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index b419889..1cfcb3f 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -111,6 +111,14 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1017", "SetWithProperty")] [InlineData("CMTK1017", "SetWithMutableField")] [InlineData("CMTK1018", "SetGeneric")] + [InlineData("CMTK1016", "SetWithInternalConstructor")] + [InlineData("CMTK1019", "SetWithAlias.Legacy")] + [InlineData("CMTK1019", "SetWithFactoryMember.B")] + [InlineData("CMTK1019", "SetWithUninitializedMember.B")] + [InlineData("CMTK1019", "SetWithTrailingCall.B")] + [InlineData("CMTK1020", "SetRecord")] + [InlineData("CMTK1011", "SetWithName")] + [InlineData("CMTK1011", "SetWithExplicitAll")] public void A_declaration_that_cannot_be_generated_is_an_error(string id, string type) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.Contains($"'{type}"), $"{id} on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); @@ -298,7 +306,20 @@ 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", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); + .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CS0535"], 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 + /// regression the list of ids alone would let through. + /// + [Fact] + public void CS0535_names_only_a_refused_closed_set() => + consumer.Errors.Where(error => error.Id == "CS0535") + .ShouldAllBe(error => RefusedClosedSet().IsMatch(error.Message), consumer.Output); + + [GeneratedRegex(@"^'(?:Consumer\.)?Set\w+(?:)?' does not implement interface member ")] + private static partial Regex RefusedClosedSet(); /// /// A generator error cannot carry a help link as an analyzer rule does (a Metalama 2026.1 @@ -312,7 +333,7 @@ public void Every_build_error_links_to_the_Build_errors_table() var errors = consumer.Errors.Where(error => error.Id.StartsWith("CMTK1", StringComparison.Ordinal)).ToList(); - errors.Select(error => error.Id).Distinct().Count().ShouldBe(19, $"Not every CMTK1000-CMTK1012 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); + errors.Select(error => error.Id).Distinct().Count().ShouldBe(21, $"Not every CMTK1000-CMTK1020 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); errors.Where(error => !error.Message.EndsWith($". See {link}", StringComparison.Ordinal)) .Select(error => $"{error.Id}: {error.Message}") .ShouldBeEmpty("These errors do not end with the link to the Build errors table."); @@ -396,6 +417,22 @@ public sealed partial class SetWithoutMembers : IClosedSet { public sealed partial class SetGeneric : IClosedSet> { public static readonly SetGeneric A = new(); } + public sealed partial class SetWithInternalConstructor : IClosedSet { public static readonly SetWithInternalConstructor A = new(); internal SetWithInternalConstructor() { } } + + public sealed partial class SetWithAlias : IClosedSet { public static readonly SetWithAlias Open = new(); public static readonly SetWithAlias Legacy = Open; } + + public sealed partial class SetWithFactoryMember : IClosedSet { public static readonly SetWithFactoryMember A = new(); public static readonly SetWithFactoryMember B = Make(); private static SetWithFactoryMember Make() => A; } + + public sealed partial class SetWithUninitializedMember : IClosedSet { public static readonly SetWithUninitializedMember A = new(); public static readonly SetWithUninitializedMember B; static SetWithUninitializedMember() { B = new(); } } + + public sealed partial class SetWithTrailingCall : IClosedSet { public static readonly SetWithTrailingCall A = new(); public static readonly SetWithTrailingCall B = new SetWithTrailingCall().Same(); private SetWithTrailingCall Same() => A; } + + public sealed partial record SetRecord : IClosedSet { public static readonly SetRecord A = new(); } + + public sealed partial class SetWithName : IClosedSet { public static readonly SetWithName A = new(); public string Name => "A"; } + + public sealed partial class SetWithExplicitAll : IClosedSet { public static readonly SetWithExplicitAll A = new(); static System.Collections.Generic.IReadOnlyList IClosedSet.All => [A]; } + public readonly record struct NotPartial : IValue; public readonly partial struct NotRecord : IValue; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 85454a5..018e263 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -101,6 +101,82 @@ public void Generic_code_reaches_a_set_through_the_interface() Names().ShouldBe("Basic,Premium"); } + /// + /// A found member is not null to the caller's nullable analysis: the generated TryParse says + /// so, as the interface does. Without it this test does not compile (CS8602, an error here). + /// + [Fact] + public void A_member_TryParse_found_is_not_null_to_the_compiler() + { + if (ProbeStatus.TryParse("Shipped", out var shipped)) shipped.Name.ShouldBe("Shipped"); + else throw new InvalidOperationException("Shipped is a member."); + } + + /// The one seam: a hand-written ToString() is kept, and the name is still what JSON and parsing use. + [Fact] + public void A_hand_written_ToString_is_kept_and_the_name_still_travels() + { + ProbeLabelled.High.ToString().ShouldBe("high priority"); + $"{ProbeLabelled.Low}".ShouldBe("low priority"); + + ProbeLabelled.High.Name.ShouldBe("High"); + JsonSerializer.Serialize(ProbeLabelled.High).ShouldBe("\"High\""); + ReferenceEquals(ProbeLabelled.Parse("High"), ProbeLabelled.High).ShouldBeTrue(); + } + + /// + /// An instance the set's own code made beside its members has no name. Before, its name was null: + /// JSON wrote null, a nullable column stored one, and both read back as nothing. + /// + [Fact] + public void An_instance_that_is_not_a_member_is_refused_wherever_its_name_is_read() + { + var unlisted = ProbeLevel.Unlisted(); + const string link = "#an-instance-that-is-not-a-member-of-its-set"; + + Should.Throw(() => unlisted.Name).Message.ShouldEndWith(link); + Should.Throw(() => unlisted.QualifiedName).Message.ShouldEndWith(link); + Should.Throw(() => unlisted.ToString()).Message.ShouldEndWith(link); + Should.Throw(() => unlisted.Match(low: () => 1, high: () => 2)).Message.ShouldEndWith(link); + Should.Throw(() => JsonSerializer.Serialize(unlisted)).Message.ShouldEndWith(link); + + ProbeLevel.High.Name.ShouldBe("High"); + } + + /// + /// Every form of new CMTK1019 accepts is generated, and an overload of a generated method + /// with another signature is the set's own: kept beside the generated one, not CMTK1011. + /// + [Fact] + public void Members_created_in_any_form_and_overloads_of_another_signature_are_kept() + { + ProbePriority.All.Select(member => member.Name).ShouldBe(["Low", "Normal", "High"]); + ProbePriority.Normal.Label.ShouldBe("normal (the default)"); + + ReferenceEquals(ProbePriority.Parse(2), ProbePriority.Normal).ShouldBeTrue(); + ReferenceEquals(ProbePriority.Parse("High"), ProbePriority.High).ShouldBeTrue(); + + var picked = ""; + ProbePriority.Low.Match(low: () => picked = "low", normal: () => picked = "normal", high: () => picked = "high"); + picked.ShouldBe("low"); + ProbePriority.High.Match(low: () => 1, normal: () => 2, high: () => 3).ShouldBe(3); + } + + /// + /// A set is a class, so a JSON null is the serializer's to read, as for any class: null, unless + /// RespectNullableAnnotations refuses it for a property that is not nullable. The converter + /// cannot refuse it itself without refusing it for an OrderStatus? property too. + /// + [Fact] + public void A_JSON_null_is_the_serializer_s_to_read() + { + const string json = """{"Status":null,"Tier":"Basic"}"""; + + JsonSerializer.Deserialize(json)!.Status.ShouldBeNull(); + Should.Throw(() => JsonSerializer.Deserialize(json, new JsonSerializerOptions { RespectNullableAnnotations = true })); + JsonSerializer.Deserialize(json, new JsonSerializerOptions { RespectNullableAnnotations = true })!.Status.ShouldBeNull(); + } + private static string Names() where TSet : class, IClosedSet => string.Join(",", TSet.All.Select(member => member.Name)); private sealed class Counter : IClosedSetVisitor @@ -109,4 +185,6 @@ private sealed class Counter : IClosedSetVisitor } private sealed record Holder(ProbeStatus Status, ProbeTier Tier); + + private sealed record OptionalHolder(ProbeStatus? Status, ProbeTier Tier); } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 4284169..2dfd70a 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -643,6 +643,33 @@ ProbeLabel static op_LessThanOrEqual(ProbeLabel, ProbeLabel) : Boolean extension static ProbeLabelExtensions.GetValue(ProbeLabel) : String extension static ProbeLabelExtensions.ValueOrNull(ProbeLabel) : String +ProbeLabelled + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + implements IClosedSet + nested ProbeLabelledJsonConverter : JsonConverter + static All : IReadOnlyList + static Parse(String) : ProbeLabelled + static TryParse(String, out ProbeLabelled) : Boolean +ProbeLevel + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + implements IClosedSet + nested ProbeLevelJsonConverter : JsonConverter + static All : IReadOnlyList + static Parse(String) : ProbeLevel + static TryParse(String, out ProbeLevel) : Boolean + static Unlisted() : ProbeLevel ProbeLocalDate CompareTo(Object) : Int32 CompareTo(ProbeLocalDate) : Int32 @@ -906,6 +933,23 @@ ProbePin static op_LessThanOrEqual(ProbePin, ProbePin) : Boolean extension static ProbePinExtensions.GetValue(ProbePin) : Int32 extension static ProbePinExtensions.ValueOrNull(ProbePin?) : Int32? +ProbePriority + Label : String + Match(Action, Action, Action) : Void + Match(Func, Func, Func) : TResult + Name : String + QualifiedName : String + Rank : Int32 + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + implements IClosedSet + nested ProbePriorityJsonConverter : JsonConverter + static All : IReadOnlyList + static Parse(Int32) : ProbePriority + static Parse(String) : ProbePriority + static TryParse(String, out ProbePriority) : Boolean ProbeRatio CompareTo(Object) : Int32 CompareTo(ProbeRatio) : Int32 diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index bba30dc..a0622f9 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -77,7 +77,8 @@ public sealed class NullDelegateTests ["Patch.Match(Func, Func, Func): onAbsent"] = [() => Patch.Value(1).Match(x => x, () => 0, Null>()), () => Patch.Null().Match(x => x, () => 0, Null>()), () => Patch.Absent().Match(x => x, () => 0, Null>())], ["Patch.IfValue(Action): action"] = [() => { Patch.Value(1).IfValue(Null>()); return null; }, () => { Patch.Null().IfValue(Null>()); return null; }, () => { Patch.Absent().IfValue(Null>()); return null; }], - ["Patch.IfPresent(Action): action"] = [() => { Patch.Value(1).IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], + ["Patch.IfPresent(Patch, Action): action"] = [() => { Patch.Value("a").IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], + ["Patch.IfPresent(Patch, Action>): action"] = [() => { Patch.Value(1).IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], // Catch has no branches: the delegate is checked before anything runs. Overloads that differ // only in type parameters are keyed with them. diff --git a/test/CodoMetis.TypeKit.Tests/PatchTests.cs b/test/CodoMetis.TypeKit.Tests/PatchTests.cs index 2d6db34..c17d2f0 100644 --- a/test/CodoMetis.TypeKit.Tests/PatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/PatchTests.cs @@ -57,6 +57,25 @@ public void Match_TryGetValue_IfValue_and_IfPresent_follow_the_state() seen.ShouldBe(["Ada", ""]); } + /// + /// A clear over a struct reaches the action as null. It reached it as default(T): a quantity + /// cleared to zero, or a struct value object handed over uninitialized. + /// + [Fact] + public void IfPresent_over_a_struct_clears_with_null_never_with_default() + { + int? quantity = 5; + + Patch.Null().IfPresent(q => quantity = q); + quantity.ShouldBeNull(); + + Patch.Value(3).IfPresent(q => quantity = q); + quantity.ShouldBe(3); + + Patch.Absent().IfPresent(q => quantity = q); + quantity.ShouldBe(3); + } + [Fact] public void ToOption_is_Some_for_a_value_and_None_for_a_clear_or_an_absence() { diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index f3244cf..a99d185 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -42,6 +42,7 @@ public sealed partial class RefusalLinkTests private const string Unreadable = "A value object could not read the input"; private const string NullToValueObject = "A null given to a value object"; private const string ClosedSetMember = "A closed set has no member of that name"; + private const string ClosedSetNotAMember = "An instance that is not a member of its set"; private enum Fault { TooLong } @@ -51,6 +52,7 @@ private enum Fault { TooLong } { ["Closed set Parse of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember("OrderStatus")), ["Closed set JSON of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson("OrderStatus")), + ["Closed set instance with no name"] = (ClosedSetNotAMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NotAMember("OrderStatus")), ["Option written as JSON"] = (InJson, () => JsonSerializer.Serialize(Option.Some(1))), ["Option read from JSON"] = (InJson, () => JsonSerializer.Deserialize>("1")), ["Option? written as a JSON null"] = (InJson, () => JsonSerializer.Serialize?>(null)), From 169a00a5ec16cbbaed92c523e30e22f8ef444a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 14:00:22 +0200 Subject: [PATCH 018/147] fix: closed sets bound by position, refused by EF's compiled model, and silent as dictionary keys From the open items of the closed-set review of b514d10, each measured first: - Match takes one Func per member in declaration order, so an argument by position followed the order: reordering two members swapped their branches and compiled. CMTK0012 (Error, 2.0.0 is a major) requires every argument named, in the declaring project too, where the call does not bind before weaving; its code fix names them as they bind now. - Match accepted a null delegate for a member not matched. Every delegate is checked before a branch is picked, as Option's and Result's are. - A set as a JSON dictionary key threw NotSupportedException both ways. The converter writes and reads the member's name as a key. - A member named Match, Parse, TryParse or ToString failed the aspect (LAMA0506, LAMA0611), one named All or Name was told by CMTK1011 to be removed, and members Open and open gave Match one parameter twice (LAMA0611, CS0100). CMTK1021 refuses each and says to rename the member. A member named New or Default was fine: Metalama escapes the keyword. - dotnet ef dbcontext optimize wrote the mapping's internal helpers out as C# in the application (CS0122), so no closed set reached a compiled model or Native AOT. ClosedSetConverter is the public converter whose static methods the compiled model calls, and the JSON reader/writer is composed with both types known: without it a closed-set parameter failed under Native AOT. A collection of members and a JSON column are tested. - The consumer smoke test's aot consumer publishes a set's JSON (a value and a key), its OpenAPI enum, its column, a parameter and a JSON column. - CatchTests.The_sample_runs_as_written raced a 100 ms HttpClient timeout against a refused connection; a timeout is a cancellation, which CatchAsync lets through. A handler that refuses at once replaces it. The EF README records three EF Core 10.0.12 limitations measured on the way: --precompile-queries reads woven types of its own project without bodies, a precompiled query cannot read a collection of converted elements (EF's own EnumToStringConverter fails alike), and an entity with any primitive collection fails to materialize in one. Seeded: the null checks with the key overrides (2 failures), CMTK0012 (7), CMTK1021 (10), the member exclusion from CMTK1011 (1), the composed JSON reader/writer under Native AOT (the parameter query), each reverted. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +- README.md | 6 +- docs/plan.md | 11 +- .../AnalyzerReleases.Unshipped.md | 1 + .../PositionalMatchArgumentCodeFixProvider.cs | 78 +++++++ .../DiagnosticIds.cs | 2 + .../PositionalMatchArgumentAnalyzer.cs | 145 ++++++++++++ src/CodoMetis.TypeKit.Analyzers/README.md | 24 +- .../ClosedSetConverter.cs | 60 +++++ .../ClosedSetMapping.cs | 31 ++- .../README.md | 28 ++- .../AspectDiagnostics.cs | 3 + .../ClosedSetAspect.Templates.cs | 33 +++ .../ClosedSetAspect.cs | 23 +- .../ClosedSetTypes.cs | 25 ++- src/CodoMetis.TypeKit.Generators/README.md | 19 +- .../ClosedSets/IClosedSet.cs | 7 +- .../PositionalMatchArgumentAnalyzerTests.cs | 212 ++++++++++++++++++ .../ClosedSetMappingTests.cs | 55 +++++ .../Probes.cs | 10 + .../BuildOutcomeTests.cs | 51 ++++- .../ClosedSetTests.cs | 48 ++++ .../GeneratedSurface.verified.txt | 13 ++ .../Analyzers/PositionalMatchSamples.cs | 34 +++ .../Base/CatchSamples.cs | 23 +- test/consumer-smoke-test.sh | 58 ++++- 26 files changed, 956 insertions(+), 57 deletions(-) create mode 100644 src/CodoMetis.TypeKit.Analyzers/CodeFixes/PositionalMatchArgumentCodeFixProvider.cs create mode 100644 src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs create mode 100644 src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetConverter.cs create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Analyzers/PositionalMatchSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 564756c..00828aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,17 @@ Semantic Versioning reserves for a major, listed under Changed as they land. signature; any other generated member written by hand is CMTK1011. An instance the set's own code makes beside its members has no name, and reading it throws rather than write a null. CMTK0002 covers a closed set without the generators. - `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name; - `CodoMetis.TypeKit.AspNetCore` describes it as a string enum. Nothing is registered per set. + CMTK1021 refuses a member named as a generated member, or one whose `Match` parameter another + member's already is (`Open` and `open`). `Match` checks every delegate for null before it picks one, + and a set is a JSON dictionary key by its name. + `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name, in a + primitive collection and a JSON column too, through a compiled model and under Native AOT + (`ClosedSetConverter` names it explicitly); `CodoMetis.TypeKit.AspNetCore` describes it as a + string enum. Nothing is registered per set. +- **`CodoMetis.TypeKit.Analyzers`**: CMTK0012, a closed set's `Match` given an argument by position. + Every parameter has the same type, one delegate per member in declaration order, so reordering two + members swapped their branches at such a call and compiled. The code fix names every argument after + the parameter it binds to. - **`CodoMetis.TypeKit`**: `Patch`, a field of a JSON Merge Patch (RFC 7396): absent, null or a value, where System.Text.Json alone cannot tell an absent property from a null one. `default` is absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never diff --git a/README.md b/README.md index db31ddc..140e6ca 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ your own, and it can be declared inside another type. | Package | Role | Metalama | |---|---|---| | [CodoMetis.TypeKit](src/CodoMetis.TypeKit/README.md) | `Option`, `Result`, `Result`, the value-object contracts, the text guards `AsText()` and `AsIdentifier()`, and the analyzers that guard them | no | -| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0011: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values; no type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`; no type without the constructor its host will look for. In Razor components too. Arrives with the base package | no | +| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0012: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values; no type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`; no type without the constructor its host will look for; no closed-set `Match` bound by position. In Razor components too. Arrives with the base package | no | | [CodoMetis.TypeKit.Generators](src/CodoMetis.TypeKit.Generators/README.md) | The compile-time generation, in the project that declares value objects | yes | | [CodoMetis.TypeKit.EntityFrameworkCore](src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) | Value objects as columns with nothing registered per type, and `.Value` in LINQ | no | | [CodoMetis.TypeKit.AspNetCore](src/CodoMetis.TypeKit.AspNetCore/README.md) | Value objects in the OpenAPI document, with the schema of the type they wrap; a `Result` as the response, a fault as problem details | no | @@ -121,7 +121,7 @@ services.AddOpenApi(options => options.AddTypeKit()); // Orde identity column; its OpenAPI schema is the one ASP.NET publishes for the wrapped type. Only what the value object refuses changes. - **Loud failures.** A declaration that cannot be generated is a build error naming the - declaration, CMTK1000 to CMTK1012, never a type with nothing in it. The analyzers make `default` + declaration, CMTK1000 to CMTK1021, never a type with nothing in it. The analyzers make `default` of a value object, an `Option` or a `Result` an error. - **Discovery by interface.** The EF Core and OpenAPI satellites recognise a value object by the attribute the generators put beside `IValueObject<,>`, whose type arguments are constrained to it, @@ -196,7 +196,7 @@ references `CodoMetis.TypeKit.Generators` and in every project that references t object is declared by implementing its interface, with no attribute and nothing registered. Templates write the generated members as ordinary C#, which a build can write out for you to read ([Reading the generated code](src/CodoMetis.TypeKit.Generators/README.md#reading-the-generated-code)). -A declaration that cannot be generated fails the build with its name on it, CMTK1000 to CMTK1012, +A declaration that cannot be generated fails the build with its name on it, CMTK1000 to CMTK1021, instead of compiling to less than it appears to. And Metalama runs the analyzers on the code it transformed, which is how they reach Razor components. diff --git a/docs/plan.md b/docs/plan.md index c9b7b06..99f4a36 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -588,10 +588,16 @@ Each phase ends green, and its guards have been proven by seeding the defect 7. **The closed-set family** ✅ 2026-10-13, after [spikes/ClosedSets](../spikes/ClosedSets/README.md), which answered the compatibility question: a generated type with static members on Metalama 2026.1 and `net10.0`, nothing newer needed. `IClosedSet` in the base package, - `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1020, the + `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1021, the discovery attribute `GeneratedClosedSetAttribute` with a typed visitor, a text column in the EF satellite and a string enum in the OpenAPI satellite. The fault bits (a kind and a - sentence per member) are step 8's, since the fault responses read them. + sentence per member) are step 8's, since the fault responses read them. Two reviews the same + day: the first's fixes are CMTK1019–1020 and the kept `ToString()`; the second's are CMTK0012 + (arguments by position), CMTK1021 (a member named as a generated member, or two sharing a + `Match` parameter), null delegates refused before a branch is picked, dictionary keys, and + `ClosedSetConverter`: the compiled model wrote the internal mapping's helpers out as C# + in the application (CS0122), so no closed set reached Native AOT. The smoke test's `aot` + consumer now publishes a set's JSON, its column, a parameter and a JSON column. 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict @@ -1615,6 +1621,7 @@ exist. | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | | CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within decision 34 | | CMTK0011 | A concrete type implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a type declaring no constructor satisfies `Public`. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | +| CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | **Rules that read calls see the source before weaving (measured 2026-09-28).** Metalama runs diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md index d724e13..ee34c25 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,3 +7,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------------------------------------------------------------- CMTK0010 | Usage | Error | TypeArgumentConstraintAnalyzer, a type argument that breaks [Abstract], [Sealed], [Record] or [Concrete] on its parameter CMTK0011 | Usage | Error | RequiredParameterlessConstructorAnalyzer, a concrete type under [RequireParameterlessConstructor] without the constructor it requires +CMTK0012 | Usage | Error | PositionalMatchArgumentAnalyzer, a closed set's Match given an argument by position diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/PositionalMatchArgumentCodeFixProvider.cs b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/PositionalMatchArgumentCodeFixProvider.cs new file mode 100644 index 0000000..f996f26 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/PositionalMatchArgumentCodeFixProvider.cs @@ -0,0 +1,78 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// The fix for CMTK0012: names every argument of a closed set's Match after the parameter it +/// is bound to now, so the call means what it meant and no longer depends on the members' order. +/// +/// +/// An argument by position after a named one is in its own position, or the call would not compile, +/// so an argument's index is its parameter's. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PositionalMatchArgumentCodeFixProvider))] +[Shared] +public sealed class PositionalMatchArgumentCodeFixProvider : CodeFixProvider +{ + /// The rule this fixes; held to the analyzer's id by its tests. + public const string DiagnosticId = "CMTK0012"; + + /// The diagnostic property carrying the parameter names; held to the analyzer's by its tests. + public const string NamesProperty = "Names"; + + /// + public override ImmutableArray FixableDiagnosticIds => [DiagnosticId]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + foreach (var diagnostic in context.Diagnostics) + { + if (!diagnostic.Properties.TryGetValue(NamesProperty, out var joined) || joined is null) continue; + if (root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf() is not { } invocation) continue; + + var names = joined.Split(','); + if (names.Length != invocation.ArgumentList.Arguments.Count) continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Name every argument of Match", + cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(invocation.ArgumentList, Named(invocation.ArgumentList, names)))), + equivalenceKey: DiagnosticId), + diagnostic); + } + } + + private static ArgumentListSyntax Named(ArgumentListSyntax list, string[] names) + { + var arguments = list.Arguments.Select((argument, index) => argument.NameColon is not null ? argument : Named(argument, names[index])); + return list.WithArguments(SyntaxFactory.SeparatedList(arguments, list.Arguments.GetSeparators())); + } + + /// The argument with name: before it, in the argument's place: what led the argument now leads the name. + private static ArgumentSyntax Named(ArgumentSyntax argument, string name) + { + var identifier = SyntaxFacts.GetKeywordKind(name) == SyntaxKind.None + ? SyntaxFactory.Identifier(name) + : SyntaxFactory.Identifier(SyntaxTriviaList.Empty, SyntaxKind.IdentifierToken, "@" + name, name, SyntaxTriviaList.Empty); + + var nameColon = SyntaxFactory.NameColon(SyntaxFactory.IdentifierName(identifier), SyntaxFactory.Token(SyntaxKind.ColonToken).WithTrailingTrivia(SyntaxFactory.Space)) + .WithLeadingTrivia(argument.GetLeadingTrivia()); + + return argument.WithoutLeadingTrivia().WithNameColon(nameColon); + } +} diff --git a/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs b/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs index 4633318..5e88edb 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs @@ -28,6 +28,8 @@ internal static class DiagnosticIds public const string RequiredParameterlessConstructor = "CMTK0011"; + public const string PositionalMatchArgument = "CMTK0012"; + /// /// The rule's section of the analyzer README on GitHub, which an IDE opens from the diagnostic. /// The README's ## CMTK000N headings give the anchors (AnalyzerHelpLinkTests). diff --git a/src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs new file mode 100644 index 0000000..3729b93 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs @@ -0,0 +1,145 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// CMTK0012: a closed set's generated Match given an argument by position. +/// +/// +/// +/// Match takes one Func<TResult> per member, in declaration order, so every +/// parameter has the same type. An argument by position is bound to whichever member is declared +/// at that place: reorder two members, and every such call swaps their branches, compiling as before. +/// Named, an argument follows its member wherever it moves, and a member renamed or removed fails the +/// call. A call with every argument named is silent. +/// +/// +/// In the project that declares the set, Metalama runs analyzers before weaving, where the generated +/// Match does not exist and the call does not bind (). +/// The receiver binds, so an unbound call named Match on a closed set with one argument per +/// member is taken for the generated one, and the parameter names are the generators' rule applied to +/// the members: the field's name with its first letter lower-cased. A call that binds counts only when +/// it binds to the generated signature; an overload the set declares by hand is its own. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PositionalMatchArgumentAnalyzer : DiagnosticAnalyzer +{ + /// The diagnostic property carrying the parameter names in order, comma-separated, for the code fix. + public const string NamesProperty = "Names"; + + private const string Match = "Match"; + + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticIds.PositionalMatchArgument, + title: "A closed set's Match given an argument by position", + messageFormat: "'{0}.Match' is given an argument by position, which binds to whichever member is declared at that place: reorder the members and the branches swap without a word. Name every argument ({1}).", + category: "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Every parameter of a closed set's Match has the same type, one delegate per member in declaration order, so the compiler cannot tell a swapped pair. A named argument follows its member, and fails the build when the member is renamed or removed.", + helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.PositionalMatchArgument) + ); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCode.AnalysisFlags); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(start => + { + if (TypeKitSymbols.Resolve(start.Compilation) is not { } symbols) return; + if (start.Compilation.GetTypeByMetadataName("System.Func`1") is not { } func) return; + + start.RegisterSyntaxNodeAction(node => Analyze(node, symbols, func), SyntaxKind.InvocationExpression); + }); + } + + private static void Analyze(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols, INamedTypeSymbol func) + { + var invocation = (InvocationExpressionSyntax)context.Node; + var arguments = invocation.ArgumentList.Arguments; + + if (arguments.All(argument => argument.NameColon is not null)) return; + if (Receiver(invocation, context.SemanticModel, context.CancellationToken) is not { } set) return; + if (set.TypeKind != TypeKind.Class || symbols.FindClosedSetMarker(set) is null) return; + + var members = Members(set); + string[] names; + + if (context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is IMethodSymbol bound) + { + if (!IsGeneratedMatch(bound.OriginalDefinition, set, members.Length, func)) return; + names = [.. bound.Parameters.Select(parameter => parameter.Name)]; + } + else + { + if (arguments.Count != members.Length) return; + names = [.. members.Select(member => ParameterName(member.Name))]; + } + + var first = arguments.First(argument => argument.NameColon is null); + context.Report(Diagnostic.Create( + Rule, + first.GetLocation(), + ImmutableDictionary.Empty.Add(NamesProperty, string.Join(",", names)), + set.Name, + string.Join(", ", names.Select(name => Escaped(name) + ":")))); + } + + /// + /// The type Match is called on: the receiver of x.Match(…) or x?.Match(…), or, + /// for a simple name, the closed set the call is written in. A type as the receiver is not an + /// instance, and Match is an instance method. + /// + private static INamedTypeSymbol? Receiver(InvocationExpressionSyntax invocation, SemanticModel model, CancellationToken cancellationToken) + { + switch (invocation.Expression) + { + case MemberAccessExpressionSyntax access when access.Name.Identifier.ValueText == Match: + if (model.GetSymbolInfo(access.Expression, cancellationToken).Symbol is ITypeSymbol) return null; + return model.GetTypeInfo(access.Expression, cancellationToken).Type as INamedTypeSymbol; + + case MemberBindingExpressionSyntax binding when binding.Name.Identifier.ValueText == Match: + return invocation.FirstAncestorOrSelf() is { } conditional + ? model.GetTypeInfo(conditional.Expression, cancellationToken).Type as INamedTypeSymbol + : null; + + case SimpleNameSyntax name when name.Identifier.ValueText == Match: + return model.GetEnclosingSymbol(invocation.SpanStart, cancellationToken)?.ContainingType; + + default: + return null; + } + } + + /// The members, as the generators find them: the public static readonly fields of the set's own type, in declaration order. + private static IFieldSymbol[] Members(INamedTypeSymbol set) => + [.. set.GetMembers().OfType().Where(field => field is { IsStatic: true, IsReadOnly: true, DeclaredAccessibility: Accessibility.Public } + && SymbolEqualityComparer.Default.Equals(field.Type, set))]; + + /// The generated signature: an instance Match<TResult> of the set itself with one Func<TResult> per member. + private static bool IsGeneratedMatch(IMethodSymbol method, INamedTypeSymbol set, int memberCount, INamedTypeSymbol func) => + method is { Name: Match, IsStatic: false, TypeParameters.Length: 1 } + && SymbolEqualityComparer.Default.Equals(method.ContainingType, set) + && method.Parameters.Length == memberCount + && method.Parameters.All(parameter => parameter.Type is INamedTypeSymbol type + && SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, func) + && SymbolEqualityComparer.Default.Equals(type.TypeArguments[0], method.TypeParameters[0])); + + /// The generators' rule for a member's parameter: its name with the first letter lower-cased (ClosedSetTypes.ParameterName). + internal static string ParameterName(string member) => char.ToLowerInvariant(member[0]) + member.Substring(1); + + /// A name as it is written at a call: a keyword needs @. + internal static string Escaped(string name) => SyntaxFacts.GetKeywordKind(name) == SyntaxKind.None ? name : "@" + name; +} diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 3f18e35..95f6bf7 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -23,9 +23,10 @@ appears to: an instance that passed no factory, or a value object that was never | CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind. | | CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call, written or inferred. A type parameter forwarded without the attribute is a breach too. | | CMTK0011 | Error | A concrete type that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. The code fix writes or changes the constructor. | +| CMTK0012 | Error | A closed set's `Match` given an argument by position, which binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | The ids are public contract and never change meaning. The generators' own build errors, -CMTK1000 to CMTK1011, come from `CodoMetis.TypeKit.Generators` and are listed in its README. +CMTK1000 to CMTK1021, come from `CodoMetis.TypeKit.Generators` and are listed in its README. Every rule also runs in `.razor` and `.cshtml` files (see [Generated code](#generated-code-and-razor)). @@ -372,6 +373,23 @@ no fix: every other constructor of such a type chains to the primary one, with a author knows. The requirement reaches a type through any chain of base classes and interfaces, and the attribute is read from metadata, so the marked type can live in another package. +## CMTK0012 + +A closed set's `Match` takes one delegate per member, in the order the members are declared, so +every parameter has the same type. An argument by position binds to whichever member is declared +at that place: reorder two members, and every such call swaps their branches and compiles as +before. A named argument follows its member, and a member renamed or removed fails the call. + +```csharp +var label = status.Match(() => "Open", () => "On its way", () => "Cancelled"); // CMTK0012: by position +var named = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); +``` + +The code fix names each argument after the parameter it binds to now, so the call keeps its +meaning. A parameter is the member's name with its first letter lower-cased, and a keyword is +escaped: a member `New` is `@new:`. An overload the set declares itself, such as +`Match(Func)`, is its own and is not reported. + ## Configuration Severity follows the usual `.editorconfig` mechanism, for instance @@ -381,8 +399,8 @@ there too, put the line in a `.globalconfig` file, which applies to every file o The errors are the rules where the code is wrong whatever surrounds it: a `default` written out (CMTK0001), a value object nothing generates (CMTK0002), validation bypassed (CMTK0004), a type -argument that breaks what its parameter declares (CMTK0010), and a constructor a host will look for -and not find (CMTK0011). A +argument that breaks what its parameter declares (CMTK0010), a constructor a host will look for +and not find (CMTK0011), and branches bound to members by their order (CMTK0012). A warning is what gets ignored, so these fail the build. The others are warnings, or for CMTK0007 Info, because correct code can look the same: an array filled in a loop straight after it is created (CMTK0005), a member a framework sets (CMTK0006), a sequence known not to be empty (CMTK0009), a diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetConverter.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetConverter.cs new file mode 100644 index 0000000..f0049b3 --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetConverter.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using System.Linq.Expressions; +using CodoMetis.TypeKit.ClosedSets; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// Stores a closed set's member as its name, and reads the member itself back with the set's own +/// TryParse. +/// +/// +/// UseTypeKit() applies it to every closed-set property by itself, so it only needs to be +/// named to configure a property explicitly. A name the set no longer has is an +/// , never a null. +/// +/// The closed set. +public sealed class ClosedSetConverter : ValueConverter + where TSet : class, IClosedSet +{ + // Both directions call a public static method of this class. EF's compiled model + // (dotnet ef dbcontext optimize, which Native AOT requires) writes these expressions out as C# in + // the application's own assembly, where an internal helper does not compile (CS0122, measured with + // EF Core 10.0.12); and an expression tree can call neither the static abstract TryParse nor + // anything with an out variable. + internal static readonly Expression> ToProvider = member => ProviderValue(member); + internal static readonly Expression> FromProvider = name => MemberNamed(name); + + /// Creates the converter. + public ClosedSetConverter() + : this(null) + { + } + + /// Creates the converter with hints for the text column. + /// Size or Unicode hints for the column. + public ClosedSetConverter(ConverterMappingHints? mappingHints) + : base(ToProvider, FromProvider, mappingHints) + { + } + + /// The value to store: the member's name. + /// Public for the code EF's compiled model generates; nothing else needs to call it. + /// The member. + /// Its name. + /// The instance is not one of the set's members. + [EditorBrowsable(EditorBrowsableState.Never)] + public static string ProviderValue(TSet member) => member.Name; + + /// The member of a stored name. + /// Public for the code EF's compiled model generates; nothing else needs to call it. + /// A name the application stored. + /// The member itself. + /// The set has no member of that name. + [EditorBrowsable(EditorBrowsableState.Never)] + public static TSet MemberNamed(string name) => + TSet.TryParse(name, out var member) + ? member + : throw new InvalidOperationException($"{typeof(TSet).Name} has no member of that name: the column holds a name the set no longer has."); +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs index abc35b4..bbaa392 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ClosedSetMapping.cs @@ -3,15 +3,24 @@ using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.CompilerServices; using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.Json; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace CodoMetis.TypeKit.EntityFrameworkCore; /// -/// A closed set as a column: its member's name, read back with the set's own TryParse. -/// Made per set through , never by -/// MakeGenericType, so it works under Native AOT as the value-object mappings do. +/// A closed set as a column: its member's name, read back with the set's own TryParse, and a +/// JSON reader/writer (primitive collections, JSON columns) over the string's. Made per set through +/// , never by MakeGenericType, so it +/// works under Native AOT as the value-object mappings do. /// +/// +/// As for a value object (), the converter is a plain +/// over the expressions of +/// , whose public methods the compiled model's code can call, and +/// the JSON reader/writer is composed here, with both types known, where EF's own composition uses +/// MakeGenericType. +/// internal abstract class ClosedSetMapping { private static readonly ConcurrentDictionary Descriptions = new(); @@ -27,20 +36,18 @@ public static ClosedSetMapping For(GeneratedClosedSetAttribute set) => public abstract ValueConverter Converter { get; } + /// The set's reader/writer over the string's, or where the text mapping has none. + protected abstract JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? text); + public RelationalTypeMapping ComposeOnto(RelationalTypeMapping text) => - (RelationalTypeMapping)text.WithComposedConverter(Converter, jsonValueReaderWriter: null); + (RelationalTypeMapping)text.WithComposedConverter(Converter, jsonValueReaderWriter: ReaderWriterOver(text.JsonValueReaderWriter)); private sealed class Of : ClosedSetMapping where TSet : class, IClosedSet { - // Plain static methods: an expression tree can call neither a static abstract member nor an out variable. - public override ValueConverter Converter { get; } = new ValueConverter(member => NameOf(member), name => MemberOf(name)); - - private static string NameOf(TSet member) => member.Name; + public override ValueConverter Converter { get; } = new ValueConverter(ClosedSetConverter.ToProvider, ClosedSetConverter.FromProvider); - private static TSet MemberOf(string name) => - TSet.TryParse(name, out var member) - ? member - : throw new InvalidOperationException($"{typeof(TSet).Name} has no member of that name: the column holds a name the set no longer has."); + protected override JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? text) => + text is JsonValueReaderWriter typed ? new JsonConvertedValueReaderWriter(typed, Converter) : null; } private sealed class Factory : IClosedSetVisitor diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 0d402bb..d3003ab 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -93,6 +93,12 @@ type, so `configurationBuilder.Properties().HavePrecision(18, 2)` reach properties and not an `Amount` that wraps one. Configure the value object instead: `configurationBuilder.Properties().HavePrecision(18, 2)`. +**Closed sets.** A closed set (`IClosedSet`) is a text column of the member's name, read back +as the member itself, in a table, a primitive collection (a JSON array of names) and a JSON column +alike, with nothing registered per set. A name the set no longer has throws when it is read, rather +than reading as null, and an instance that is no member throws when it is written. +`ClosedSetConverter` names the converter for an explicit configuration. + **`Option` and `Result` are not columns.** EF cannot map them, and the model says so. An optional value is `T?` on the entity, and `ToOption()` converts it where the domain wants an `Option`. @@ -146,14 +152,14 @@ that experimentally: dotnet ef dbcontext optimize --precompile-queries --nativeaot ``` -`UseTypeKit()` works there: value objects map through the compiled model, `.Value` translates in -precompiled queries, a value-object parameter binds, a stored value reads back without the rules, and -a key over an integer is generated by the database, in the native binary (the consumer smoke test -runs exactly that). A compiled model without Native AOT +`UseTypeKit()` works there: value objects and closed sets map through the compiled model, a JSON +column included, `.Value` translates in precompiled queries, a value-object or closed-set parameter +binds, a stored value reads back without the rules, and a key over an integer is generated by the +database, in the native binary (the consumer smoke test runs exactly that). A compiled model without Native AOT works the same way. This package adds no trim or AOT warning of its own; EF's `DbContext` constructors report theirs, which EF's documentation covers. -Three EF limitations to know: +Five EF limitations to know, measured with EF Core 10.0.12: - **A struct value object that wraps a reference type** (`string`, `Uri`) cannot be a property in a compiled model: EF cannot write its sentinel, since `default(ProductCode)` converts to `null`, and @@ -163,4 +169,14 @@ Three EF limitations to know: interceptors). - **Precompiled queries need the converter `UseTypeKit()` composes.** They cast a property's converter to the type it had at design time, which the compiled model does not recreate for a - `ValueObjectConverter<,>` named in `HasConversion`. + `ValueObjectConverter<,>` or a `ClosedSetConverter<>` named in `HasConversion`. +- **Precompiled queries compile the project they are in without the generators' bodies.** + `--precompile-queries` reads the generated members as declarations only, so a value object or a + closed set declared in that project fails it (CS0534, CS0177 in the generated code). Declare them + in another project, as a domain library usually is. +- **A collection of converted elements cannot be read by a precompiled query**, a `List` or + a `List` as much as a `List` stored as text by EF's own converter: + precompilation fails with "Encountered a constant of unsupported type 'ValueConverter`2'". An + entity with any primitive collection, a `List` too, fails to materialize in a precompiled + query (a `BadImageFormatException` from an accessor EF generates). A query compiled at run time, + without Native AOT, handles both. diff --git a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs index 655a62c..c38b3ec 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs @@ -80,4 +80,7 @@ internal static class AspectDiagnostics public static readonly DiagnosticDefinition ClosedSetRecord = new("CMTK1020", Severity.Error, "'{0}' is a record, so it is not generated: 'with' copies a member into an instance that is none, and a record compares by value where a closed set's members are singletons. Declare it a sealed partial class." + SeeBuildErrors); + + public static readonly DiagnosticDefinition<(INamedType Type, string Member, string Reason)> ClosedSetMemberNameTaken = + new("CMTK1021", Severity.Error, "'{0}.{1}' {2}, so '{0}' is not generated: rename the member." + SeeBuildErrors); } diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs index 2bd2114..ef3fd10 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -87,10 +87,19 @@ public static bool TryParseTemplate([NotNullWhen(true)] string? name, [NotNullWh throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); } + /// + /// Every delegate is checked before a branch is picked, as Option and Result check + /// theirs: a null for a member not matched passed until that member first arrived. + /// [Template] public TResult MatchTemplate(Func first) { var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var parameter in meta.Target.Parameters) + { + ArgumentNullException.ThrowIfNull(parameter.Value, parameter.Name); + } + var index = meta.CompileTime(0); foreach (var member in args.Members) { @@ -128,4 +137,28 @@ public void JsonWriteTemplate(Utf8JsonWriter writer, dynamic? value, JsonSeriali { writer.WriteStringValue((string)value!.Name); } + + /// + /// A dictionary key is the member's name too, which the serializer refused for a converter without + /// these two (NotSupportedException, both ways). Written as it is, as the value is: a + /// DictionaryKeyPolicy renames only on writing, so a renamed key would not read back. + /// + [Template] + public dynamic? JsonReadAsPropertyNameTemplate(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var name = reader.GetString(); + foreach (var candidate in args.Members) + { + if (name == candidate.Name) return candidate.Value; + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson(args.Set.Name); + } + + [Template] + public void JsonWriteAsPropertyNameTemplate(Utf8JsonWriter writer, dynamic? value, JsonSerializerOptions options) + { + writer.WritePropertyName((string)value!.Name); + } } diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index 298054b..2b8e230 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -57,9 +57,21 @@ public override void BuildAspect(IAspectBuilder builder) foreach (var property in target.Properties.Where(p => p.IsStatic && p.Accessibility == Accessibility.Public && p.Type.Equals(target))) problems.Add(ClosedSetMemberNotAField.WithArguments((target, property.Name, "a static property"))); + // A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and + // TryParse, LAMA0611 beside ToString), and two members whose names differ only in their first + // letter's case gave Match one parameter name twice (LAMA0611, CS0100). + var generatedNames = ClosedSetTypes.GeneratedNames(target); + foreach (var member in members.Where(member => generatedNames.Contains(member.Name))) + problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, "has the name of a member the generators introduce"))); + foreach (var shared in members.GroupBy(ClosedSetTypes.ParameterName).Where(group => group.Count() > 1)) + { + foreach (var member in shared.Skip(1)) + problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, $"gives Match the parameter '{shared.Key}', as '{target.Name}.{shared.First().Name}' does"))); + } + // 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.Count); + var declaredByHand = ClosedSetTypes.GeneratedMembersDeclaredByHand(target, members); if (declaredByHand.Count > 0) problems.Add(HandWrittenGeneratedMember.WithArguments((target, string.Join(", ", declaredByHand), (declaredByHand.Count == 1 ? "the generators introduce it themselves; remove it" : "the generators introduce these themselves; remove them") @@ -137,8 +149,8 @@ public override void BuildAspect(IAspectBuilder builder) m.Accessibility = Accessibility.Public; m.AddAttribute(CodeAnnotations.CompilerGenerated); var func = m.Parameters[0].Type; - m.Parameters[0].Name = Camel(members[0].Name); - foreach (var member in members.Skip(1)) m.AddParameter(Camel(member.Name), func); + m.Parameters[0].Name = ClosedSetTypes.ParameterName(members[0]); + foreach (var member in members.Skip(1)) m.AddParameter(ClosedSetTypes.ParameterName(member), func); }); var converter = builder.IntroduceClass($"{target.Name}JsonConverter", buildType: t => @@ -150,12 +162,11 @@ public override void BuildAspect(IAspectBuilder builder) }); converter.IntroduceMethod(nameof(JsonReadTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Read"; m.ReturnType = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); converter.IntroduceMethod(nameof(JsonWriteTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Write"; m.Parameters[1].Type = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + converter.IntroduceMethod(nameof(JsonReadAsPropertyNameTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "ReadAsPropertyName"; m.ReturnType = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + converter.IntroduceMethod(nameof(JsonWriteAsPropertyNameTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "WriteAsPropertyName"; m.Parameters[1].Type = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); builder.IntroduceAttribute(AttributeConstruction.Create(typeof(JsonConverterAttribute), constructorArguments: [converter.Target])); builder.IntroduceAttribute(AttributeConstruction.Create(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)).WithTypeArguments(target))); builder.IntroduceAttribute(CodeAnnotations.CompilerGenerated); } - - [CompileTime] - private static string Camel(string name) => char.ToLowerInvariant(name[0]) + name[1..]; } diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index b4bb96d..d444acc 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -19,6 +19,17 @@ public static bool IsClosedSet(INamedType type) => public static IReadOnlyList Members(INamedType type) => [.. type.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(type) && f.Writeability == Writeability.ConstructorOnly)]; + /// The names of what the generators introduce into the set itself; nothing else in it may have one (CMTK1011, CMTK1021). + public static IReadOnlyList GeneratedNames(INamedType set) => + ["Name", "QualifiedName", "All", "Match", "Parse", "TryParse", "ToString", "_name", "_all", $"{set.Name}JsonConverter"]; + + /// + /// The name of 's parameter of Match: its own, with the first letter + /// lower-cased. A keyword is escaped where the code is written, so New is @new: at a + /// call. The analyzer's CMTK0012 names the arguments by the same rule. + /// + public static string ParameterName(IField member) => char.ToLowerInvariant(member.Name[0]) + member.Name[1..]; + /// /// Whether is initialized with new of the set where it is declared, /// and with nothing else: new(), new(1, null), new OrderStatus(), each with an @@ -129,21 +140,23 @@ private static string WithoutComments(string text) } /// - /// The members the generators introduce, written by hand: a property, field or nested type of a - /// generated name, a method with a generated signature, or an explicit implementation of + /// The members the generators introduce, written by hand: a property, field, event or nested type + /// of a generated name, a method with a generated signature, or an explicit implementation of /// IClosedSet<TSelf>. Each failed the aspect with a Metalama error naming no fix. An - /// overload of another signature is the set's own and is kept, as is ToString(), the seam. + /// overload of another signature is the set's own and is kept, as is ToString(), the seam. A + /// member of the set with a generated name is CMTK1021's, which says to rename it. /// - public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, int memberCount) + public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) { var text = TypeFactory.GetType(SpecialType.String); + var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); return [ - .. ValueObjectDeclaration.MembersNamed(set, methods: false, "Name", "QualifiedName", "All", "_name", "_all", $"{set.Name}JsonConverter"), + .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(set)]).Where(declared => !memberNames.Contains(declared)), .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && SameParameters(method, text)).Select(ValueObjectDeclaration.Describe), .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && SameParameters(method, text, set)).Select(ValueObjectDeclaration.Describe), - .. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, memberCount)).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), ]; diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index f15f58b..2f7f429 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -223,14 +223,18 @@ var json = JsonSerializer.Serialize(Tier.Premium); // "Premium" ``` Members are singletons, so equality is reference equality, and a member added later fails every -`Match` until it is handled. `Parse` and the JSON converter refuse an unknown name with an -exception naming the set, never the input. The EF Core satellite maps a closed set to a text column -of the member's name, and the ASP.NET Core satellite describes it as a string enum of the names, -with nothing registered per set. +`Match` until it is handled. Every parameter of `Match` has the same type, so an argument by +position would follow the declaration order and swap branches when two members are reordered: the +analyzer requires every argument named (CMTK0012), and its code fix names them. `Match` checks every +delegate for null before it picks one. `Parse` and the JSON converter refuse an unknown name with an +exception naming the set, never the input, and a set is a dictionary key in JSON by its name too. +The EF Core satellite maps a closed set to a text column of the member's name, and the ASP.NET Core +satellite describes it as a string enum of the names, with nothing registered per set. Each member is initialized with `new` where it is declared (CMTK1019), and the constructors are -private (CMTK1016); a record is refused (CMTK1020). A `ToString()` written by hand is kept, and the -name is still what JSON, parsing and the column use; so is an overload of a generated method with +private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated +member, nor share its `Match` parameter with another (`Open` and `open`), CMTK1021. A `ToString()` +written by hand is kept, and the name is still what JSON, parsing and the column use; so is an overload of a generated method with another signature, such as `Parse(int rank)`. Any other generated member written by hand is CMTK1011. An instance the set's own code creates beside its members has no name, so reading its name throws rather than write a null. A JSON `null` reads as `null`, as for any class, unless the serializer's @@ -337,8 +341,9 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1018 | A closed set that is generic or nested in a generic type. | | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the converter), or whose `Match` parameter, its name with the first letter lower-cased, another member's already is. | -The analyzers that come with the base package add CMTK0001 to CMTK0011; see +The analyzers that come with the base package add CMTK0001 to CMTK0012; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). ## Reading the generated code diff --git a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs index 9194561..e527f87 100644 --- a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs +++ b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs @@ -19,8 +19,10 @@ namespace CodoMetis.TypeKit.ClosedSets; /// /// /// Unlike an enum, a closed set's switch is complete by construction: Match takes -/// a delegate per member, so a member added later fails every call site until it is handled. The -/// type is a sealed class, so members are singletons and equality is reference equality. +/// a delegate per member, so a member added later fails every call site until it is handled. Its +/// arguments are named (CMTK0012), since by position they would follow the declaration order, and +/// it refuses a null delegate before picking one. The type is a sealed class, so members are +/// singletons and equality is reference equality. /// /// /// The type must be a sealed partial class (CMTK1013, CMTK1014) with at least one member (CMTK1015), @@ -28,6 +30,7 @@ namespace CodoMetis.TypeKit.ClosedSets; /// and not a record (CMTK1020), since every one of those would make an instance the generators cannot /// see as a member. Each member is initialized with new where it is declared, and with nothing /// else (CMTK1019): a member initialized with another member would give that instance two names. A +/// member takes no name the generators use, nor another's Match parameter (CMTK1021). A /// ToString() written by hand is kept, as is an overload of a generated method with another /// signature; any other member the generators introduce, written by hand, is CMTK1011. /// diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs new file mode 100644 index 0000000..f5bfb36 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs @@ -0,0 +1,212 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0012: a closed set's Match given an argument by position, which reordering the members +/// swaps silently. +/// +/// +/// The verifier never weaves, so the sets below declare by hand what the generators would: the +/// woven one as it binds from another project, and one without Match as the project declaring +/// the set sees it, where Metalama runs analyzers before weaving and the call does not bind. +/// BuildOutcomeTests holds the second form to a real woven build. +/// +public sealed class PositionalMatchArgumentAnalyzerTests +{ + private const string Sets = + """ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using CodoMetis.TypeKit.ClosedSets; + + namespace Subjects + { + public sealed class Status : IClosedSet + { + public static readonly Status Open = new(); + public static readonly Status Shipped = new(); + public static readonly Status New = new(); + + private Status() { } + + public string Name => ""; + public string QualifiedName => ""; + public static IReadOnlyList All => [Open, Shipped, New]; + public static bool TryParse([NotNullWhen(true)] string? name, [MaybeNullWhen(false)] out Status member) { member = null; return false; } + + public TResult Match(Func open, Func shipped, Func @new) => open(); + + // An overload of another signature, the set's own. + public string Match(Func describe) => describe(this); + } + + // As the declaring project sees a set before weaving: no Match yet. + public sealed class Unwoven : IClosedSet + { + public static readonly Unwoven Low = new(); + public static readonly Unwoven High = new(); + + private Unwoven() { } + + public string Name => ""; + public string QualifiedName => ""; + public static IReadOnlyList All => [Low, High]; + public static bool TryParse([NotNullWhen(true)] string? name, [MaybeNullWhen(false)] out Unwoven member) { member = null; return false; } + } + + // Not a closed set: a Match of the same shape is nobody's concern here. + public sealed class Other + { + public TResult Match(Func a, Func b) => a(); + } + } + + """; + + private static AnalyzerTest Test(string code, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(Sets + code); + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static DiagnosticResult Cmtk0012(string set, string names) => + new DiagnosticResult("CMTK0012", DiagnosticSeverity.Error).WithLocation(0).WithArguments(set, names); + + [Fact] + public void The_descriptor_is_the_published_contract() + { + var rule = new PositionalMatchArgumentAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + rule.Id.ShouldBe("CMTK0012"); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); + rule.IsEnabledByDefault.ShouldBeTrue(); + } + + /// The fix lives in another assembly, so the id and the property name it reads are held to the analyzer's here. + [Fact] + public void The_code_fix_names_the_rule_and_the_property_the_analyzer_writes() + { + new PositionalMatchArgumentCodeFixProvider().FixableDiagnosticIds.ShouldBe(["CMTK0012"]); + PositionalMatchArgumentCodeFixProvider.NamesProperty.ShouldBe(PositionalMatchArgumentAnalyzer.NamesProperty); + } + + [Fact] + public Task Every_argument_named_is_silent() => + Test( + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match(shipped: () => 2, open: () => 1, @new: () => 3); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task An_argument_by_position_is_reported_with_every_name_a_keyword_escaped() => + Test( + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match({|#0:() => 1|}, () => 2, () => 3); + } + """, + new DiagnosticResult("CMTK0012", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'Status.Match' is given an argument by position, which binds to whichever member is declared at that place: reorder the members and the branches swap without a word. Name every argument (open:, shipped:, @new:).")) + .RunAsync(TestContext.Current.CancellationToken); + + /// C# 7.2 lets a named argument in its own position precede one by position; the one by position is still reported. + [Fact] + public Task One_argument_by_position_among_named_ones_is_reported_at_it() => + Test( + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match(open: () => 1, {|#0:() => 2|}, @new: () => 3); + } + """, + Cmtk0012("Status", "open:, shipped:, @new:")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_conditional_call_is_reported() => + Test( + """ + static class Use + { + static int? Pick(Subjects.Status? s) => s?.Match({|#0:() => 1|}, () => 2, () => 3); + } + """, + Cmtk0012("Status", "open:, shipped:, @new:")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// Before weaving, in the project that declares the set, the call does not bind; one argument per + /// member on a closed set is the generated Match, and the names are the generators' rule. + /// + [Fact] + public Task An_unbound_call_with_one_argument_per_member_is_reported_with_the_generators_names() => + Test( + """ + static class Use + { + static int Pick(Subjects.Unwoven u) => u.{|CS1061:Match|}({|#0:() => 1|}, () => 2); + } + """, + Cmtk0012("Unwoven", "low:, high:")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task An_unbound_call_with_another_count_is_not_taken_for_the_generated_Match() => + Test( + """ + static class Use + { + static int Pick(Subjects.Unwoven u) => u.{|CS1061:Match|}(() => 1); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task An_overload_the_set_declares_and_a_Match_of_another_type_are_silent() => + Test( + """ + static class Use + { + static string Describe(Subjects.Status s) => s.Match(status => status.Name); + + static int Pick(Subjects.Other o) => o.Match(() => 1, () => 2); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_names_each_argument_by_position_after_the_parameter_it_binds_to_now() => + new CodeFixTest( + Sets + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match( + {|#0:() => 1|}, + shipped: () => 2, + () => 3); + } + """, + Sets + """ + static class Use + { + static int Pick(Subjects.Status s) => s.Match( + open: () => 1, + shipped: () => 2, + @new: () => 3); + } + """) + { + ExpectedDiagnostics = { Cmtk0012("Status", "open:, shipped:, @new:") }, + } + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs index 747fa81..a6b4098 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs @@ -61,6 +61,54 @@ public void An_instance_that_is_not_a_member_is_never_stored() cause.Message.ShouldEndWith("#an-instance-that-is-not-a-member-of-its-set"); } + /// A collection of members is a JSON array of their names, as a collection of strings would be. + [Fact] + public void A_collection_of_members_is_a_JSON_column_of_names_and_reads_the_members_back() + { + using (var db = new SetDb(Options)) + { + db.Database.EnsureCreated(); + db.Tickets.Add(new Ticket { Id = 4, History = [ProbeStatus.Open, ProbeStatus.Shipped] }); + db.SaveChanges(); + } + + using (var db = new SetDb(Options)) + { + var ticket = db.Tickets.Single(t => t.Id == 4); + ticket.History.ShouldBe([ProbeStatus.Open, ProbeStatus.Shipped]); + ReferenceEquals(ticket.History[1], ProbeStatus.Shipped).ShouldBeTrue(); + + db.Tickets.Count(t => t.History.Contains(ProbeStatus.Shipped)).ShouldBe(1); + db.Database.SqlQueryRaw("SELECT History AS Value FROM Tickets WHERE Id = 4").Single().ShouldBe("""["Open","Shipped"]"""); + } + } + + /// A member inside a JSON column is its name there too, and a query reaches into it. + [Fact] + public void A_member_inside_a_JSON_column_is_its_name_and_a_query_compares_by_it() + { + using (var db = new SetDb(Options)) + { + db.Database.EnsureCreated(); + db.Tickets.Add(new Ticket { Id = 5, Delivery = new Delivery { Status = ProbeStatus.Cancelled, Tier = ProbeTier.Premium } }); + db.SaveChanges(); + } + + using (var db = new SetDb(Options)) + { + var ticket = db.Tickets.Single(t => t.Delivery.Status == ProbeStatus.Cancelled); + ReferenceEquals(ticket.Delivery.Tier, ProbeTier.Premium).ShouldBeTrue(); + db.Database.SqlQueryRaw("SELECT Delivery AS Value FROM Tickets WHERE Id = 5").Single().ShouldBe("""{"Status":"Cancelled","Tier":"Premium"}"""); + } + } + + public sealed class Delivery + { + public ProbeStatus Status { get; set; } = ProbeStatus.Open; + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + } + public sealed class Ticket { public int Id { get; set; } @@ -70,10 +118,17 @@ public sealed class Ticket public ProbeTier Tier { get; set; } = ProbeTier.Basic; public ProbeLevel? Level { get; set; } + + public List History { get; set; } = []; + + public Delivery Delivery { get; set; } = new(); } public sealed class SetDb(DbContextOptions options) : DbContext(options) { public DbSet Tickets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().ComplexProperty(ticket => ticket.Delivery, delivery => delivery.ToJson()); } } diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 7c658ea..6f48277 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -264,3 +264,13 @@ public void Match(Action low, Action normal, Action high) else high(); } } + +/// +/// A closed set whose members are named as C# keywords once lower-cased: Match's parameters +/// are new and default, written @new: and @default: at a call. +/// +public sealed partial class ProbeKeyword : IClosedSet +{ + public static readonly ProbeKeyword New = new(); + public static readonly ProbeKeyword Default = new(); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 1cfcb3f..978d52f 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -119,9 +119,38 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1020", "SetRecord")] [InlineData("CMTK1011", "SetWithName")] [InlineData("CMTK1011", "SetWithExplicitAll")] + [InlineData("CMTK1021", "SetWithMemberNamedParse.Parse")] + [InlineData("CMTK1021", "SetWithMemberNamedToString.ToString")] + [InlineData("CMTK1021", "SetWithMemberNamedAll.All")] + [InlineData("CMTK1021", "SetWithCasedMembers.open")] public void A_declaration_that_cannot_be_generated_is_an_error(string id, string type) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.Contains($"'{type}"), $"{id} on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + /// + /// A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and + /// TryParse, LAMA0611 beside ToString), or was CMTK1011 telling the author to remove it; two members + /// whose names differ in their first letter's case gave Match one parameter twice (LAMA0611, CS0100). + /// + [Theory] + [InlineData("SetWithMemberNamedParse.Parse", "has the name of a member the generators introduce")] + [InlineData("SetWithMemberNamedAll.All", "has the name of a member the generators introduce")] + [InlineData("SetWithCasedMembers.open", "gives Match the parameter 'open', as 'SetWithCasedMembers.Open' does")] + public void A_member_name_the_generators_take_is_refused_with_the_reason(string member, string reason) => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1021" && error.Message.StartsWith($"'{member}' {reason}, so ") && error.Message.Contains("rename the member"), consumer.Output); + + [Fact] + public void A_member_is_never_told_to_be_removed_as_a_hand_written_generated_member() => + consumer.Errors.ShouldNotContain(error => error.Id == "CMTK1011" && error.Message.Contains("SetWithMemberNamed"), consumer.Output); + + /// + /// Before weaving, where Metalama runs analyzers, the generated Match does not exist in the project + /// that declares the set; CMTK0012 still reports a call by position, with the parameter names the + /// generators give, which the named call beside it compiles with. + /// + [Fact] + public void CMTK0012_reports_a_call_by_position_with_the_generators_names_in_the_declaring_project() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK0012" && error.Message.StartsWith("'SetToMatch.Match' is given an argument by position") && error.Message.Contains("(@new:, open:)"), consumer.Output); + /// /// Every value object over another one is refused, so a cycle would be refused without this /// reason too, but not told that it loops, or through which types. @@ -306,7 +335,7 @@ 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", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); + .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK0012", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); /// /// CS0535 is allowed above only for a refused closed set, whose interface members the generators @@ -333,7 +362,7 @@ public void Every_build_error_links_to_the_Build_errors_table() var errors = consumer.Errors.Where(error => error.Id.StartsWith("CMTK1", StringComparison.Ordinal)).ToList(); - errors.Select(error => error.Id).Distinct().Count().ShouldBe(21, $"Not every CMTK1000-CMTK1020 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); + errors.Select(error => error.Id).Distinct().Count().ShouldBe(22, $"Not every CMTK1000-CMTK1021 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); errors.Where(error => !error.Message.EndsWith($". See {link}", StringComparison.Ordinal)) .Select(error => $"{error.Id}: {error.Message}") .ShouldBeEmpty("These errors do not end with the link to the Build errors table."); @@ -433,6 +462,24 @@ public sealed partial class SetWithoutMembers : IClosedSet { public sealed partial class SetWithExplicitAll : IClosedSet { public static readonly SetWithExplicitAll A = new(); static System.Collections.Generic.IReadOnlyList IClosedSet.All => [A]; } + public sealed partial class SetWithMemberNamedParse : IClosedSet { public static readonly SetWithMemberNamedParse A = new(); public static readonly SetWithMemberNamedParse Parse = new(); } + + public sealed partial class SetWithMemberNamedToString : IClosedSet { public static readonly new SetWithMemberNamedToString ToString = new(); } + + public sealed partial class SetWithMemberNamedAll : IClosedSet { public static readonly SetWithMemberNamedAll All = new(); } + + public sealed partial class SetWithCasedMembers : IClosedSet { public static readonly SetWithCasedMembers Open = new(); public static readonly SetWithCasedMembers open = new(); } + + // Generated; the calls below are CMTK0012's, in the project that declares the set. + public sealed partial class SetToMatch : IClosedSet { public static readonly SetToMatch New = new(); public static readonly SetToMatch Open = new(); } + + public static class MatchCalls + { + public static int ByPosition(SetToMatch set) => set.Match(() => 1, () => 2); + + public static int Named(SetToMatch set) => set.Match(@new: () => 1, open: () => 2); + } + public readonly record struct NotPartial : IValue; public readonly partial struct NotRecord : IValue; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 018e263..fee2bdb 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -177,6 +177,54 @@ public void A_JSON_null_is_the_serializer_s_to_read() JsonSerializer.Deserialize(json, new JsonSerializerOptions { RespectNullableAnnotations = true })!.Status.ShouldBeNull(); } + /// + /// Every delegate is checked before a branch is picked, as Option's and Result's are: a null for a + /// member not matched passed until that member first arrived. + /// + [Fact] + public void Match_refuses_a_null_delegate_whichever_member_it_is_called_on() + { + foreach (var member in ProbeStatus.All) + { + Should.Throw(() => member.Match(open: null!, shipped: () => 2, cancelled: () => 3)).ParamName.ShouldBe("open"); + Should.Throw(() => member.Match(open: () => 1, shipped: null!, cancelled: () => 3)).ParamName.ShouldBe("shipped"); + Should.Throw(() => member.Match(open: () => 1, shipped: () => 2, cancelled: null!)).ParamName.ShouldBe("cancelled"); + } + } + + /// A dictionary key is the member's name, as the value is; the serializer refused a set as a key both ways before. + [Fact] + public void A_dictionary_key_is_the_name_and_reads_the_member_itself_back() + { + var counts = new Dictionary { [ProbeStatus.Open] = 1, [ProbeStatus.Cancelled] = 3 }; + + var json = JsonSerializer.Serialize(counts); + json.ShouldBe("""{"Open":1,"Cancelled":3}"""); + + var back = JsonSerializer.Deserialize>(json)!; + back.Keys.ShouldBe([ProbeStatus.Open, ProbeStatus.Cancelled]); + back[ProbeStatus.Cancelled].ShouldBe(3); + + // As the value: no naming policy renames a key, which would not read back. + JsonSerializer.Serialize(counts, new JsonSerializerOptions { DictionaryKeyPolicy = JsonNamingPolicy.CamelCase }).ShouldBe(json); + + var refusal = Should.Throw(() => JsonSerializer.Deserialize>("""{"Nope":1}""")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + + Should.Throw(() => JsonSerializer.Serialize(new Dictionary { [ProbeLevel.Unlisted()] = 1 })) + .Message.ShouldEndWith("#an-instance-that-is-not-a-member-of-its-set"); + } + + /// A member's name lower-cased may be a keyword; the parameter is still that name, escaped where it is written. + [Fact] + public void A_member_whose_parameter_is_a_keyword_is_matched_by_the_escaped_name() + { + ProbeKeyword.Default.Match(@new: () => "new", @default: () => "default").ShouldBe("default"); + ProbeKeyword.New.Name.ShouldBe("New"); + typeof(ProbeKeyword).GetMethod("Match")!.GetParameters().Select(parameter => parameter.Name).ShouldBe(["new", "default"]); + } + private static string Names() where TSet : class, IClosedSet => string.Join(",", TSet.All.Select(member => member.Name)); private sealed class Counter : IClosedSetVisitor diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 2dfd70a..f65fc86 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -604,6 +604,19 @@ ProbeIsbn static op_Inequality(ProbeIsbn, ProbeIsbn) : Boolean extension static ProbeIsbnExtensions.GetValue(ProbeIsbn) : ProbeIsbnText extension static ProbeIsbnExtensions.ValueOrNull(ProbeIsbn?) : ProbeIsbnText +ProbeKeyword + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + implements IClosedSet + nested ProbeKeywordJsonConverter : JsonConverter + static All : IReadOnlyList + static Parse(String) : ProbeKeyword + static TryParse(String, out ProbeKeyword) : Boolean ProbeLabel $() : ProbeLabel CompareTo(Object) : Int32 diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/PositionalMatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/PositionalMatchSamples.cs new file mode 100644 index 0000000..3ade1bf --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/PositionalMatchSamples.cs @@ -0,0 +1,34 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace ReadmeSamples.Analyzers.PositionalMatch; + +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} + +public static class Labels +{ + public static (string Label, string Named) Of(OrderStatus status) + { +#pragma warning disable CMTK0012 // what the sample shows the rule reporting + // sample: CodoMetis.TypeKit.Analyzers/cmtk0012 + var label = status.Match(() => "Open", () => "On its way", () => "Cancelled"); // CMTK0012: by position + var named = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); + // end sample +#pragma warning restore CMTK0012 + + return (label, named); + } +} + +public sealed class PositionalMatchTests +{ + [Fact] + public void Both_calls_pick_the_branch_of_the_member_while_the_order_is_unchanged() + { + Labels.Of(OrderStatus.Shipped).ShouldBe(("On its way", "On its way")); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs index 7eaecc4..2df768e 100644 --- a/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Base/CatchSamples.cs @@ -4,7 +4,7 @@ namespace ReadmeSamples.Base.Catching; public static class Outcomes { - public static async Task RunAsync(string source, string target, HttpClient client, Uri url) + public static async Task> RunAsync(string source, string target, HttpClient client, Uri url) { // sample: CodoMetis.TypeKit/catch Result parsed = Result.Catch(() => int.Parse("42")); // Success: 42 @@ -14,7 +14,8 @@ public static async Task RunAsync(string source, string target, HttpClient clien Result fetched = await Result.CatchAsync(() => client.GetByteArrayAsync(url)); // waits for the task; its exception is the error // end sample - _ = (parsed, refused, narrowed, copied, fetched); + _ = (parsed, refused, narrowed, copied); + return fetched; } } @@ -39,9 +40,12 @@ public async Task The_sample_runs_as_written() var target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); try { - using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromMilliseconds(100) }; - await Outcomes.RunAsync(source, target, client, new Uri("http://127.0.0.1:1/unreachable")); + using var client = new HttpClient(new RefusingHandler()); + var fetched = await Outcomes.RunAsync(source, target, client, new Uri("http://127.0.0.1:1/unreachable")); + File.Exists(target).ShouldBeTrue(); + fetched.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeOfType(); } finally { @@ -49,4 +53,15 @@ public async Task The_sample_runs_as_written() File.Delete(target); } } + + /// + /// Refuses every request at once. A real socket with a short timeout raced: when the timeout fired + /// before the connection was refused, HttpClient threw a cancellation, which CatchAsync lets + /// through, and the test failed on a loaded machine. + /// + private sealed class RefusingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromException(new HttpRequestException("Connection refused.")); + } } diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index 76ad00b..61233e6 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -565,10 +565,19 @@ run dotnet add package NodaTime --version "$(central_version NodaTime)" cat > Shop.cs <<'CSHARP' using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace Shop; +// A closed set: its name in JSON, as a value and a dictionary key, a string enum in OpenAPI, and a +// text column in EF Core, a JSON column's included. +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); +} + // One per JSON strategy: a reader method (Guid), the serializer's contract for a number, the // fallback with a built-in converter (Uri, an enum), a string, and parsing through a TypeConverter. public readonly partial record struct OrderId : IValue; @@ -670,6 +679,12 @@ await Check("stored", async () => return JsonSerializer.Deserialize("\"abc\"", store)!.Value; }); await Check("option-refused", async () => JsonSerializer.Serialize(new Holder(Option.Some(1)), ShopJson.Default.Holder)); +await Check("set-json", async () => JsonSerializer.Serialize(new Tracking(OrderStatus.Shipped, new() { [OrderStatus.Open] = 1 }), ShopJson.Default.Tracking)); +await Check("set-json-read", async () => +{ + var tracking = JsonSerializer.Deserialize("""{"Status":"Open","Counts":{"Shipped":2}}""", ShopJson.Default.Tracking)!; + return $"{ReferenceEquals(tracking.Status, OrderStatus.Open)} {tracking.Counts[OrderStatus.Shipped]}"; +}); // Parsing, including through a TypeConverter (NodaTime's LocalDate). await Check("parse", async () => $"{Quantity.Parse("42", null).Value} {Weekday.Parse("Monday", null).Value} {Link.Parse("https://p.test/", null).Value} {Day.Parse("2026-09-27", null).Value:yyyy-MM-dd}"); @@ -695,6 +710,7 @@ await using var app = builder.Build(); app.MapOpenApi(); app.MapGet("/orders/{id}", (OrderId id, Quantity? limit) => new OrderView(id, [Quantity.From(1)], Amount.From(1.5m))); app.MapPost("/orders", (OrderView body) => body.Amount); +app.MapPost("/tracking", (Tracking body) => body.Status.Name); app.MapPatch("/orders/{id}", (OrderId id, OrderPatch patch) => $"{patch.Code.Match(code => $"Value({code.Value})", () => "Null", () => "Absent")} {patch.Note.Match(note => $"Value({note})", () => "Null", () => "Absent")}"); app.MapGet("/codes/{text}", (string text) => Code.Create(text).ToHttpResult()); await app.StartAsync(); @@ -711,6 +727,7 @@ var schemas = document?["components"]?["schemas"]; Console.WriteLine($"openapi-component={schemas?["OrderId"]?.ToJsonString() ?? "missing"}"); Console.WriteLine($"openapi-parameter={document?["paths"]?["/orders/{id}"]?["get"]?["parameters"]?[0]?["schema"]?.ToJsonString() ?? "missing"}"); Console.WriteLine($"openapi-items={schemas?["OrderView"]?["properties"]?["quantities"]?["items"]?.ToJsonString() ?? "missing"}"); +Console.WriteLine($"openapi-set={schemas?["OrderStatus"]?.ToJsonString() ?? "missing"}"); await Check("bound", async () => ((int)(await http.GetAsync($"/orders/{Guid.NewGuid()}?limit=3")).StatusCode).ToString()); await Check("malformed", async () => ((int)(await http.GetAsync("/orders/nope")).StatusCode).ToString()); // A merge patch through the context: the patch's converter hides Code and string from the generator, @@ -737,16 +754,20 @@ public sealed record OrderPatch(Patch Code, Patch Note); public sealed record Holder(Option Value); +public sealed record Tracking(OrderStatus Status, Dictionary Counts); + // Lists no wrapped type: the generated converters must not need one. [JsonSerializable(typeof(Line))] [JsonSerializable(typeof(Holder))] [JsonSerializable(typeof(Code))] [JsonSerializable(typeof(Amount))] +[JsonSerializable(typeof(Tracking))] internal partial class ShopJson : JsonSerializerContext; // The web host's: what the endpoints exchange, and what their value objects wrap, for the schemas. [JsonSerializable(typeof(OrderView))] [JsonSerializable(typeof(OrderPatch))] +[JsonSerializable(typeof(Tracking))] [JsonSerializable(typeof(Amount))] [JsonSerializable(typeof(Code))] [JsonSerializable(typeof(string))] @@ -780,13 +801,28 @@ var id = OrderId.From(Guid.Parse("0199aaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee")); await using var db = new StoreDb(); await db.Database.OpenConnectionAsync(); -await db.Database.ExecuteSqlRawAsync("CREATE TABLE Orders (Id TEXT NOT NULL PRIMARY KEY, Code TEXT NOT NULL, Quantity INTEGER NOT NULL)"); +await db.Database.ExecuteSqlRawAsync("CREATE TABLE Orders (Id TEXT NOT NULL PRIMARY KEY, Code TEXT NOT NULL, Quantity INTEGER NOT NULL, Status TEXT NOT NULL, Delivery TEXT NOT NULL)"); await Check("ef-insert", async () => { - db.Orders.Add(new Order { Id = id, Code = Code.FromKnownGood("ABC"), Quantity = Quantity.From(2) }); + db.Orders.Add(new Order { Id = id, Code = Code.FromKnownGood("ABC"), Quantity = Quantity.From(2), Status = OrderStatus.Shipped, Delivery = new() { Status = OrderStatus.Open, Quantity = Quantity.From(3) } }); return (await db.SaveChangesAsync()).ToString(); }); db.ChangeTracker.Clear(); +await Check("ef-set-stored", async () => +{ + await using var command = db.Database.GetDbConnection().CreateCommand(); + command.CommandText = "SELECT Status || ' ' || Delivery FROM Orders"; + return (string)(await command.ExecuteScalarAsync())!; +}); +var shipped = OrderStatus.Shipped; +await Check("ef-set-parameter", async () => (await db.Orders.Where(o => o.Status == shipped).SingleAsync()).Status.Name); +db.ChangeTracker.Clear(); +await Check("ef-json-column", async () => +{ + var order = await db.Orders.Where(o => o.Delivery.Status == OrderStatus.Open).SingleAsync(); + return $"{order.Delivery.Status.Name} {order.Delivery.Quantity.Value}"; +}); +db.ChangeTracker.Clear(); await Check("ef-value", async () => (await db.Orders.Where(o => o.Code.Value.StartsWith("AB")).SingleAsync()).Quantity.Value.ToString()); db.ChangeTracker.Clear(); await Check("ef-parameter", async () => (await db.Orders.Where(o => o.Id == id).SingleAsync()).Code.Value); @@ -814,6 +850,15 @@ public class Order public OrderId Id { get; set; } public Code Code { get; set; } = null!; public Quantity Quantity { get; set; } + public OrderStatus Status { get; set; } = OrderStatus.Open; + public Delivery Delivery { get; set; } = new(); +} + +// Mapped to a JSON column: a closed set and a value object inside it. +public class Delivery +{ + public OrderStatus Status { get; set; } = OrderStatus.Open; + public Quantity Quantity { get; set; } } // A key over an integer, which UseTypeKit() makes an identity key as it would be for an int. @@ -829,6 +874,9 @@ public sealed class StoreDb : DbContext public DbSet Shipments => Set(); protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite("Data Source=:memory:").UseTypeKit(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().ComplexProperty(o => o.Delivery, delivery => delivery.ToJson()); } CSHARP @@ -862,12 +910,15 @@ assert_contains "openapi=200" "$work/aot.out" "the OpenAPI assert_contains 'openapi-component={"type":"string","format":"uuid"}' "$work/aot.out" "OrderId is a uuid component" assert_contains 'openapi-parameter={"type":"string","format":"uuid"}' "$work/aot.out" "the route parameter is a uuid" assert_contains 'openapi-items={"$ref":"#/components/schemas/Quantity"}' "$work/aot.out" "a list of value objects keeps its items" +assert_contains 'openapi-set={"enum":["Open","Shipped"],"type":"string"}' "$work/aot.out" "a closed set is a string enum of its members' names" assert_contains "bound=200" "$work/aot.out" "a route parameter binds through the generated TryParse" assert_contains 'result-success="ABC"' "$work/aot.out" "ToHttpResult answers a success with its value" assert_contains "result-fault=422 application/problem+json NotUpperCase" "$work/aot.out" "and a fault as problem details with its mapped status" assert_contains "result-responses=200,400,422" "$work/aot.out" "the document lists the value and each mapped status" assert_contains "malformed=400" "$work/aot.out" "a malformed route parameter is a 400" assert_contains 'patch=Value(ABC) Null' "$work/aot.out" "a Patch reads a value object and a null through the context under Native AOT" +assert_contains 'set-json={"Status":"Shipped","Counts":{"Open":1}}' "$work/aot.out" "a closed set writes its name through the context, as a value and as a dictionary key" +assert_contains "set-json-read=True 2" "$work/aot.out" "and reads the members themselves back" assert_contains "patch-refused=400" "$work/aot.out" "a value object a patch wraps still refuses" assert_contains 'openapi-patch={"type":["null","string"],"description":"A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it."} required=none' "$work/aot.out" "a patch property is its wrapped type's schema, inlined and nullable, and not required" assert_contains "ef-insert=1" "$work/aot.out" "EF Core writes value objects through the compiled model" @@ -875,6 +926,9 @@ assert_contains "ef-value=2" "$work/aot.out" ".Value trans assert_contains "ef-parameter=ABC" "$work/aot.out" "a value-object parameter is mapped at run time" assert_contains "ef-stored=lower" "$work/aot.out" "a stored value the rules refuse is read without them" assert_contains "ef-identity=1,2" "$work/aot.out" "a key over an integer is generated by the database" +assert_contains 'ef-set-stored=Shipped {"Quantity":3,"Status":"Open"}' "$work/aot.out" "a closed set is its name in a column and in a JSON column, through the compiled model" +assert_contains "ef-set-parameter=Shipped" "$work/aot.out" "a closed-set parameter is mapped at run time" +assert_contains "ef-json-column=Open 3" "$work/aot.out" "a JSON column reads a closed set and a value object back in a precompiled query" [[ $failed -eq 0 ]] || { echo; echo "consumer smoke test FAILED"; exit 1; } echo From 5d2f5835ec7374be027394d604406ea9c745bda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 14:08:19 +0200 Subject: [PATCH 019/147] fix: Result.Catch answers an HttpClient timeout as the error Every overload let an OperationCanceledException through, and HttpClient throws a TaskCanceledException over a TimeoutException when its Timeout elapses: CatchAsync(() => client.GetAsync(...)) threw on the most common failure of the call the README shows it with (measured with a 1 ms timeout). A timeout is the call failing, not the caller cancelling, so a cancellation over a TimeoutException is caught by every overload, and any other cancellation still passes through (decision 46). The test uses a real HttpClient whose handler waits for its token, so the timeout is the only way out and nothing races. Seeded: the old filter fails two tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- docs/plan.md | 10 +++ src/CodoMetis.TypeKit/README.md | 5 +- src/CodoMetis.TypeKit/ResultExtensions.cs | 40 ++++++--- .../ResultCatchTests.cs | 81 +++++++++++++++++++ 5 files changed, 126 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00828aa..557dc1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,10 @@ Semantic Versioning reserves for a major, listed under Changed as they land. runs the call and answers `Result`, or `Result` for an action; `Catch` catches that type only and lets any other propagate; `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` - passes through every overload, a delegate that returns a `Task` given to `Catch` is a compile error - that names `CatchAsync`, and a delegate that returns null throws as `Result.Success(null)` does. + passes through every overload, unless it is a timeout (one over a `TimeoutException`, as + `HttpClient` throws when its `Timeout` elapses), which is the error; a delegate that returns a + `Task` given to `Catch` is a compile error that names `CatchAsync`, and a delegate that returns + null throws as `Result.Success(null)` does. - **The closed-set family**, the release's headline: `IClosedSet` in `CodoMetis.TypeKit`, generated by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static readonly` field, with constant data through a private constructor where the members carry some. The diff --git a/docs/plan.md b/docs/plan.md index 99f4a36..1da126c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1454,6 +1454,16 @@ Still open: `2.0.0-preview.N` branch of 2026-10-12 becomes a preview of 2.0.0 itself from `main`; the .NET 11 measurement is a 3.0.0 spike with no release attached. +46. **A timeout caught by `Result.Catch` is the error** (2026-10-13, phase 13; 45 is the docs + branch's). Every overload let an `OperationCanceledException` through, and `HttpClient` throws + one over a `TimeoutException` when its `Timeout` elapses, so `CatchAsync(() => client.GetAsync(…))` + threw on the most common failure of the call the README shows it with, and a sample test that + raced a 100 ms timeout against a refused port failed whenever the timeout won (measured). A + timeout is the call failing, not the caller cancelling: an `OperationCanceledException` whose + `InnerException` is a `TimeoutException` is caught, every other one still passes through. No + `CancellationToken` overload: the shape tells the two apart without one, and a token cancelled by + the caller still surfaces as cancellation. + ## 9. Option and Result `Result`, `Result` and `Option`, with the `Option.None()`, `Result.Success()` and diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 75529a7..a75647f 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -164,7 +164,10 @@ type says what was expected to fail; both type arguments are spelled, since C# i and `Catch(() => int.Parse(s))` with one is the command form, which discards the number. `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through all of them: a cancellation is not an outcome, -and caught it would read as a failure that `Recover` then runs on. A delegate that returns a `Task` +and caught it would read as a failure that `Recover` then runs on. A timeout is the exception: one +over a `TimeoutException` is how `HttpClient` reports its `Timeout` elapsing, which is the call +failing, so it is the error, and a narrowed `Catch` names `TaskCanceledException` or +`OperationCanceledException` to catch it alone. A delegate that returns a `Task` given to `Catch` is a compile error, since the task's exception would be thrown after `Catch` had returned; a `ValueTask` needs `.AsTask()` or an `async` lambda, as above. A delegate that returns null throws, as `Result.Success(null)` does. `Map`, `Bind` and the rest are unchanged: a selector diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 24cbeeb..0d2ebcc 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -38,9 +38,12 @@ public static Error Error(T error) where T : notnull => /// /// /// An passes through: a cancellation is not an outcome, - /// and caught it would read as a failure and Recover would run on it. A delegate that - /// returns a is refused at compile time, since the task's exception is thrown - /// after this call returns; waits for it. + /// and caught it would read as a failure and Recover would run on it. One whose + /// is a is the error: that is + /// how reports its Timeout elapsing, a failure of + /// the call and not the caller's cancellation. A delegate that returns a is + /// refused at compile time, since the task's exception is thrown after this call returns; + /// waits for it. /// /// /// A delegate that returns null throws , as @@ -60,7 +63,7 @@ public static Result Catch(Func action) where T : notnull { value = action(); } - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (!IsCancellation(exception)) { return Result.Error(exception); } @@ -86,7 +89,7 @@ public static Result Catch(Action action) { action(); } - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (!IsCancellation(exception)) { return Result.Error(exception); } @@ -119,7 +122,7 @@ public static Result Catch(Func action) { value = action(); } - catch (TException exception) when (exception is not OperationCanceledException) + catch (TException exception) when (!IsCancellation(exception)) { return Result.Error(exception); } @@ -150,7 +153,7 @@ public static Result Catch(Action action) { action(); } - catch (TException exception) when (exception is not OperationCanceledException) + catch (TException exception) when (!IsCancellation(exception)) { return Result.Error(exception); } @@ -158,6 +161,15 @@ public static Result Catch(Action action) return Result.Success(); } + /// + /// Whether is a cancellation, which every Catch lets through: an + /// that is no timeout. + /// throws a over a when its + /// Timeout elapses, which is the call failing, so that one is caught. + /// + private static bool IsCancellation(Exception exception) => + exception is OperationCanceledException { InnerException: not TimeoutException }; + /// Refused: the task's exception is thrown after Catch has returned. Use . /// /// More specific than for a delegate returning a task, so the @@ -184,8 +196,10 @@ public static Result Catch(Action action) /// /// /// An passes through, so a cancelled token surfaces as - /// cancellation. A delegate returning a ValueTask needs .AsTask() or an async - /// lambda: a ValueTask overload beside this one is ambiguous for every async lambda. + /// cancellation; a timeout, which throws as a cancellation + /// over a , is the error. A delegate returning a ValueTask + /// needs .AsTask() or an async lambda: a ValueTask overload beside this one is + /// ambiguous for every async lambda. /// See for null. /// /// The asynchronous call whose outcome is wanted. @@ -201,7 +215,7 @@ public static async Task> CatchAsync(Func> actio { value = await action().ConfigureAwait(false); } - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (!IsCancellation(exception)) { return Result.Error(exception); } @@ -224,7 +238,7 @@ public static async Task> CatchAsync(Func action) { await action().ConfigureAwait(false); } - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (!IsCancellation(exception)) { return Result.Error(exception); } @@ -253,7 +267,7 @@ public static async Task> CatchAsync(Func.Error(exception); } @@ -278,7 +292,7 @@ public static async Task> CatchAsync(Func a { await action().ConfigureAwait(false); } - catch (TException exception) when (exception is not OperationCanceledException) + catch (TException exception) when (!IsCancellation(exception)) { return Result.Error(exception); } diff --git a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs index aac6cf9..71427cf 100644 --- a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs @@ -117,6 +117,87 @@ public async Task CatchAsync_lets_a_cancelled_token_through_as_cancellation() await Should.ThrowAsync(() => Result.CatchAsync(() => Task.Delay(10, cts.Token))); } + /// + /// HttpClient reports its Timeout elapsing as a over a + /// : the call failed, and nobody cancelled it. A handler that waits + /// for its token leaves the timeout as the only way out, so this does not race. + /// + [Fact] + public async Task CatchAsync_answers_an_HttpClient_timeout_as_the_error() + { + using var client = new HttpClient(new WaitingHandler()) { Timeout = TimeSpan.FromMilliseconds(1) }; + + var fetched = await Result.CatchAsync(() => client.GetByteArrayAsync(new Uri("http://timeout.test/"))); + + fetched.TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBeOfType().InnerException.ShouldBeOfType(); + } + + [Fact] + public async Task Every_overload_answers_a_timeout_as_the_error() + { + Result.Catch(int () => throw Timeout).TryGetValue(out _, out var valued).ShouldBeFalse(); + valued.ShouldBeSameAs(Timeout); + Result.Catch(void () => throw Timeout).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(Timeout); + Result.Catch(() => throw Timeout).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(Timeout); + Result.Catch(void () => throw Timeout).TryGetError(out var narrowedCommand).ShouldBeTrue(); + narrowedCommand.ShouldBeSameAs(Timeout); + + (await Result.CatchAsync(TimesOutLaterAsync)).TryGetValue(out _, out var later).ShouldBeFalse(); + later.ShouldBeSameAs(Timeout); + (await Result.CatchAsync(TimesOutLaterCommandAsync)).TryGetError(out var laterCommand).ShouldBeTrue(); + laterCommand.ShouldBeSameAs(Timeout); + (await Result.CatchAsync(TimesOutLaterAsync)).TryGetValue(out _, out var laterNarrowed).ShouldBeFalse(); + laterNarrowed.ShouldBeSameAs(Timeout); + (await Result.CatchAsync(TimesOutLaterCommandAsync)).TryGetError(out var laterNarrowedCommand).ShouldBeTrue(); + laterNarrowedCommand.ShouldBeSameAs(Timeout); + } + + /// A timeout is still the exception it is: a narrowed catch of another type lets it propagate. + [Fact] + public async Task A_narrowed_catch_of_another_type_lets_a_timeout_propagate() + { + Should.Throw(() => Result.Catch(() => throw Timeout)); + await Should.ThrowAsync(() => Result.CatchAsync(TimesOutLaterAsync)); + } + + /// Only a timeout is the error: a cancellation over any other inner exception, or none, still passes through. + [Fact] + public async Task A_cancellation_that_is_no_timeout_still_passes_through() + { + var cancelled = new OperationCanceledException("cancelled", new IOException("the stream closed")); + + Should.Throw(() => Result.Catch(int () => throw cancelled)); + Should.Throw(() => Result.Catch(void () => throw new TaskCanceledException())); + await Should.ThrowAsync(() => Result.CatchAsync(async Task () => { await Task.Yield(); throw cancelled; })); + } + + private static readonly TaskCanceledException Timeout = new("The request was canceled due to the configured HttpClient.Timeout elapsing.", new TimeoutException()); + + private static async Task TimesOutLaterAsync() + { + await Task.Yield(); + throw Timeout; + } + + private static async Task TimesOutLaterCommandAsync() + { + await Task.Yield(); + throw Timeout; + } + + /// Answers nothing until its token is cancelled, which only the client's timeout does. + private sealed class WaitingHandler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken); + throw new InvalidOperationException("The client's timeout is the only way out."); + } + } + /// A success over null is the delegate's defect, not an outcome, and is not caught. [Fact] public async Task A_delegate_that_returns_null_is_refused_rather_than_caught() From b1bf4284c9a00fef2852605ddd6b5035af460ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 14:18:23 +0200 Subject: [PATCH 020/147] fix: FirstOrNone on a query runs its predicate in the query, not on every row in memory The IEnumerable overloads were the only ones, so on an EF Core query FirstOrNone(predicate) and FirstOrError(predicate, error) compiled the predicate, read the whole table and ran it in memory: no WHERE, no LIMIT, no warning. IQueryable overloads take an expression and Take(1), so the provider runs both (WHERE ... LIMIT @p). LastOrNone and LastOrError on a query are compile errors: a query has no last element until it is ordered, and they would read every row (decision 47). CodoMetis.TypeKit.EntityFrameworkCore adds FirstOrNoneAsync, with or without a predicate, in EF Core's namespace. CMTK0009 names it for FirstOrDefaultAsync where the project references the satellite, and names FirstOrNone and Skip(index).FirstOrNone() for a query's FirstOrDefault and ElementAtOrDefault. Measured under Native AOT: neither is precompiled by EF 10.0.12 ("Query wasn't precompiled"), so the EF README gives the call-site form there. Seeded: without the expression overload, the binding tests and the EF translation tests fail (EF.Functions.Like throws in memory, the SQL has no WHERE). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++ docs/plan.md | 13 +++ .../DefaultProducingCallAnalyzer.cs | 26 ++++-- src/CodoMetis.TypeKit.Analyzers/README.md | 6 +- .../README.md | 19 ++++ .../TypeKitQueryableExtensions.cs | 56 ++++++++++++ src/CodoMetis.TypeKit/OptionExtensions.cs | 54 +++++++++++ src/CodoMetis.TypeKit/README.md | 4 +- src/CodoMetis.TypeKit/ResultExtensions.cs | 25 ++++++ .../DefaultProducingCallAnalyzerTests.cs | 33 +++++-- .../QueryOptionTests.cs | 89 +++++++++++++++++++ .../EntityFrameworkCoreSamples.cs | 13 +++ .../QueryableOptionTests.cs | 89 +++++++++++++++++++ 13 files changed, 423 insertions(+), 18 deletions(-) create mode 100644 src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs create mode 100644 test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 557dc1b..1d57e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,20 @@ Semantic Versioning reserves for a major, listed under Changed as they land. arrived, with the joiners refused too. Both answer `Result`, where `Blank` is the caller's to read as an error or as absence and `Hidden` is a refusal in both; a null receiver is `Blank`. A value object over either is one line, shown in the README. +- **`CodoMetis.TypeKit`**: `FirstOrNone()`, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` + on an `IQueryable`, which hand the predicate to the query provider as an expression and ask it + for one element, a `WHERE` and a `LIMIT` in SQL. `CodoMetis.TypeKit.EntityFrameworkCore` adds + `FirstOrNoneAsync`, with or without a predicate, in EF Core's namespace; CMTK0009 names it for + `FirstOrDefaultAsync` where the project references the satellite, and names `FirstOrNone` for + `Queryable.FirstOrDefault`. + +### Changed + +- **`CodoMetis.TypeKit`**: `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` on an EF Core + query, or any `IQueryable`, bind to the new overloads: the predicate runs in the query, where the + `IEnumerable` ones read every row and ran it in memory. A predicate the provider cannot + translate now throws where it ran in memory before. `LastOrNone` and `LastOrError` on a query are + compile errors that say to order the query and take the first, or to call `AsEnumerable()`. ## 1.5.1 — 2026-10-11 diff --git a/docs/plan.md b/docs/plan.md index 1da126c..8f43f54 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -598,6 +598,11 @@ Each phase ends green, and its guards have been proven by seeding the defect `ClosedSetConverter`: the compiled model wrote the internal mapping's helpers out as C# in the application (CS0122), so no closed set reached Native AOT. The smoke test's `aot` consumer now publishes a set's JSON, its column, a parameter and a JSON column. + 7a. **The research picks** (2026-10-13): a research agent reviewed what 2.0.0 lacks against the + peers and the issue trackers, and the user took its top five, each a commit: + `FirstOrNone`/`FirstOrError` on a query and EF's `FirstOrNoneAsync` ✅ (decision 47); a closed + set's `[TypeConverter]` and `IParsable`; Serilog for closed sets; continuations of a + `Task>`; `Transpose`, `SingleOrNone` and `ElementAtOrNone`. 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict @@ -1463,6 +1468,14 @@ Still open: `InnerException` is a `TimeoutException` is caught, every other one still passes through. No `CancellationToken` overload: the shape tells the two apart without one, and a token cancelled by the caller still surfaces as cancellation. +47. **`FirstOrNone` on a query runs in the query** (2026-10-13, phase 13). The `IEnumerable` + overloads were the only ones, so `db.Orders.FirstOrNone(o => o.Id == id)` read the table and ran + the predicate in memory, with no warning (reproduced: no `WHERE`, no `LIMIT`). `IQueryable` + overloads take an expression and `Take(1)`, a break only for a predicate the provider cannot + translate, which now throws: a major's to make. `LastOrNone` and `LastOrError` on a query are + `[Obsolete(error: true)]`, as `Catch`'s task trap is, since a query has no last element until it + is ordered. EF's `FirstOrNoneAsync` lives in the satellite, in EF's namespace; under Native AOT + neither is precompiled (measured), which the EF README says with the call-site form to use. ## 9. Option and Result diff --git a/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs index 50fbc46..65fd89b 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs @@ -142,8 +142,11 @@ private static Dictionary Forms(Compilation compilation, Ty const string pastTheEnd = " for an index past the end"; const string defaultValue = "Pass a default value"; - // FirstOrNone and friends would run a query on the client; a nullable projection is translated. + // A nullable projection is translated by any query provider. FirstOrNone on a query asks the + // provider for one element; LastOrNone has no query form, since a query has no last element + // until it is ordered. const string nullableFirst = "Select a nullable first, as in Select(x => ({0}?)x), so that nothing found is null"; + const string firstOrNoneInQuery = "Use FirstOrNone, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => ({0}?)x)"; var enumerable = compilation.GetTypeByMetadataName("System.Linq.Enumerable"); var queryable = compilation.GetTypeByMetadataName("System.Linq.Queryable"); @@ -152,20 +155,20 @@ private static Dictionary Forms(Compilation compilation, Ty // before Enumerable's. They have no overload that takes a default value; Enumerable's does. var immutableArray = compilation.GetTypeByMetadataName("System.Linq.ImmutableArrayExtensions"); - foreach (var (name, advice, circumstance) in new[] + foreach (var (name, advice, circumstance, queryAdvice) in new[] { - ("FirstOrDefault", "Use FirstOrNone, which returns an Option, or pass a default value", nothingFound), - ("LastOrDefault", "Use LastOrNone, which returns an Option, or pass a default value", nothingFound), - ("SingleOrDefault", defaultValue, nothingFound), - ("ElementAtOrDefault", "Use Skip(index).FirstOrNone(), which returns an Option", pastTheEnd), - ("DefaultIfEmpty", defaultValue, " for an empty sequence"), + ("FirstOrDefault", "Use FirstOrNone, which returns an Option, or pass a default value", nothingFound, firstOrNoneInQuery), + ("LastOrDefault", "Use LastOrNone, which returns an Option, or pass a default value", nothingFound, nullableFirst), + ("SingleOrDefault", defaultValue, nothingFound, nullableFirst), + ("ElementAtOrDefault", "Use Skip(index).FirstOrNone(), which returns an Option", pastTheEnd, "Use Skip(index).FirstOrNone(), which returns an Option and asks the query for one element"), + ("DefaultIfEmpty", defaultValue, " for an empty sequence", nullableFirst), }) { var produced = name == "DefaultIfEmpty" ? Produced.TypeArgument : Produced.Returned; AddWithoutDefaultValue(forms, enumerable, name, new Form(Kind.Sequence, produced, circumstance, advice)); AddWithoutDefaultValue(forms, immutableArray, name, new Form(Kind.Sequence, produced, circumstance, advice)); - AddWithoutDefaultValue(forms, queryable, name, new Form(Kind.Sequence, produced, circumstance, nullableFirst)); + AddWithoutDefaultValue(forms, queryable, name, new Form(Kind.Sequence, produced, circumstance, queryAdvice)); } // The asynchronous forms, whose default comes back in a ValueTask or Task: T is their type @@ -175,6 +178,11 @@ private static Dictionary Forms(Compilation compilation, Ty var asyncEnumerables = compilation.GetTypesByMetadataName("System.Linq.AsyncEnumerable"); var entityFramework = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions"); + // FirstOrNoneAsync is the EF Core satellite's, so it is named only where the project can call it. + var firstOrNoneAsync = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.TypeKitQueryableExtensions") is not null + ? "Use FirstOrNoneAsync, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => ({0}?)x)" + : nullableFirst; + foreach (var (name, advice, circumstance) in new[] { ("FirstOrDefaultAsync", defaultValue, nothingFound), @@ -189,7 +197,7 @@ private static Dictionary Forms(Compilation compilation, Ty AddWithoutDefaultValue(forms, asyncEnumerable, name, new Form(Kind.Sequence, Produced.TypeArgument, circumstance, advice)); } - AddWithoutDefaultValue(forms, entityFramework, name, new Form(Kind.Sequence, Produced.TypeArgument, circumstance, nullableFirst)); + AddWithoutDefaultValue(forms, entityFramework, name, new Form(Kind.Sequence, Produced.TypeArgument, circumstance, name == "FirstOrDefaultAsync" ? firstOrNoneAsync : nullableFirst)); } // List.Find and its kind return default(T) when nothing matches, as FirstOrDefault does. diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 95f6bf7..72a3d0d 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -296,8 +296,10 @@ var first = ids.FirstOrDefault(); // CMTK0009: an OrderId nob | `Enumerable.FirstOrDefault`, `LastOrDefault`, and `ImmutableArray`'s own | `FirstOrNone`, `LastOrNone`, which return an `Option`, or the overload that takes a default value | | `Enumerable.SingleOrDefault`, `DefaultIfEmpty`, and `ImmutableArray`'s own | the overload that takes a default value | | `Enumerable.ElementAtOrDefault`, and `ImmutableArray`'s own | `Skip(index).FirstOrNone()` | -| The same on `Queryable` | select a nullable first, `Select(x => (OrderId?)x).FirstOrDefault()`, which a query provider translates | -| EF Core's `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync`, `ElementAtOrDefaultAsync` | the same nullable projection, `Select(x => (OrderId?)x).FirstOrDefaultAsync()`; EF Core has no overload that takes a default value | +| `Queryable.FirstOrDefault`, `ElementAtOrDefault` | `FirstOrNone`, `Skip(index).FirstOrNone()`, which ask the query for one element, or the nullable projection below | +| The rest on `Queryable` | select a nullable first, `Select(x => (OrderId?)x).LastOrDefault()`, which a query provider translates | +| EF Core's `FirstOrDefaultAsync` | `FirstOrNoneAsync` of `CodoMetis.TypeKit.EntityFrameworkCore`, named where the project references it, or the nullable projection | +| EF Core's `LastOrDefaultAsync`, `SingleOrDefaultAsync`, `ElementAtOrDefaultAsync` | the same nullable projection, `Select(x => (OrderId?)x).SingleOrDefaultAsync()`; EF Core has no overload that takes a default value | | `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync`, `DefaultIfEmpty` on an `IAsyncEnumerable` | the overload that takes a default value | | `ElementAtOrDefaultAsync` on an `IAsyncEnumerable` | the nullable projection | | `Find`, `FindLast` of a `List`, an array (`Array.Find`) or an `ImmutableList` | `FirstOrNone(predicate)`, `LastOrNone(predicate)` | diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index d3003ab..0602e1c 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -128,6 +128,25 @@ memory is ordinal for a string, as it is for any string column. The mapping is tested on SQLite and PostgreSQL, the SQL of each translation is pinned on PostgreSQL, and every wrapped-type family makes a round trip through a real PostgreSQL. +## An Option for nothing found + +```csharp +Option order = await db.Orders.FirstOrNoneAsync(o => o.Id == id); // the database runs the condition and returns one row +Option earliest = db.Orders.OrderBy(o => o.PlacedAt).FirstOrNone(); // the same, synchronous +Result found = db.Orders.FirstOrError(o => o.Id == id, OrderFault.NotFound); +``` + +`FirstOrNoneAsync`, and the base package's `FirstOrNone` and `FirstOrError` on a query, ask the +database for one row and let it run the condition, `WHERE … LIMIT @p`, where `FirstOrDefaultAsync` +hands back a `default` value object for nothing found (CMTK0009). On a query, `LastOrNone` and +`LastOrError` are compile errors: a query has no last row until it is ordered, so order it the +other way and take the first, or call `AsEnumerable()` to read every row on purpose. + +Under Native AOT, EF runs only the queries it precompiled, and it precompiles a query where the +application calls one of EF's own operators, which these are not: they throw "Query wasn't +precompiled" there (measured with EF Core 10.0.12). Write `Where(…).Take(1).ToListAsync()` at the +call site instead, and take the first element of the list. + ## Reading back skips validation A column is read through `IValueObjectMaterializer<,>`, not through `Create`. A rule added to a diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs new file mode 100644 index 0000000..0a70bdb --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs @@ -0,0 +1,56 @@ +using System.Linq.Expressions; +using CodoMetis.TypeKit; + +// The namespace of EF Core's own FirstOrDefaultAsync, so the same using reaches both. +namespace Microsoft.EntityFrameworkCore; + +/// +/// FirstOrNoneAsync: the first element of a query as an , asked of the +/// database, where FirstOrDefaultAsync hands back a default for nothing found. +/// +/// +/// The condition and the limit run in the query, WHERE … LIMIT @p, as they do for the base +/// package's FirstOrNone on a query. Under Native AOT, EF runs only the queries it precompiled, +/// and it precompiles a query where the application calls one of its own operators, which these are +/// not: they throw "Query wasn't precompiled" there (measured with EF Core 10.0.12), so write +/// Where(…).Take(1).ToListAsync() at the call site instead. +/// +public static class TypeKitQueryableExtensions +{ + /// The query. + /// The type of the elements. + extension(IQueryable source) where T : notnull + { + /// The first element of the query, or None: the database is asked for one. + /// Cancels the query. + /// The first element, or None if the query has none. + /// The first element is null. + public Task> FirstOrNoneAsync(CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + + return FirstOfAsync(source.Take(1), cancellationToken); + } + + /// The first element that matches, or None: the database runs the condition, and is asked for one. + /// The condition to match, translated to SQL. + /// Cancels the query. + /// The first matching element, or None if nothing matches. + /// The first matching element is null. + public Task> FirstOrNoneAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + + return FirstOfAsync(source.Where(predicate).Take(1), cancellationToken); + } + } + + private static async Task> FirstOfAsync(IQueryable limited, CancellationToken cancellationToken) where T : notnull + { + await foreach (var element in limited.AsAsyncEnumerable().WithCancellation(cancellationToken).ConfigureAwait(false)) + return Option.Some(element); + + return Option.None(); + } +} diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index f16fce1..1dcbd3d 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -325,6 +325,60 @@ public Option LastOrNone(Func predicate) => .LastOrDefault(); } + /// The query. + /// The type of the elements. + /// + /// A query is asked for one element, so the provider runs the condition and the limit where the + /// query runs: a WHERE and a LIMIT in SQL. Through the overloads, an + /// was read in full and its condition compiled and run in memory, on + /// every row of the table. + /// + extension(IQueryable source) where T : notnull + { + /// The first element of the query, or None: the provider is asked for one. + /// The first element, or None if the query has none. + /// The first element is null. + public Option FirstOrNone() + { + ArgumentNullException.ThrowIfNull(source); + + return FirstOf(source.Take(1)); + } + + /// The first element that matches, or None: the provider runs the condition, and is asked for one. + /// The condition to match, translated by the query provider. + /// The first matching element, or None if nothing matches. + /// The first matching element is null. + public Option FirstOrNone(System.Linq.Expressions.Expression> predicate) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + + return FirstOf(source.Where(predicate).Take(1)); + } + + /// Refused: a query has no last element until it is ordered, and this would read every row to find one. + /// Never: the overload does not compile. + [Obsolete("A query has no last element until it is ordered, and LastOrNone would read every row to find one. Order it the other way and use FirstOrNone, or call AsEnumerable() to read it all on purpose.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public Option LastOrNone() => throw new NotSupportedException(); + + /// Refused: a query has no last element until it is ordered, and this would read every row to find one. + /// The condition to match. + /// Never: the overload does not compile. + [Obsolete("A query has no last element until it is ordered, and LastOrNone would read every row to find one. Order it the other way and use FirstOrNone, or call AsEnumerable() to read it all on purpose.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public Option LastOrNone(System.Linq.Expressions.Expression> predicate) => throw new NotSupportedException(); + } + + /// The first element a query limited to one answers, or None. + private static Option FirstOf(IQueryable limited) where T : notnull + { + using var enumerator = limited.GetEnumerator(); + + return enumerator.MoveNext() ? Some(enumerator.Current) : None(); + } + /// The dictionary. /// The type of the keys. /// The type of the values. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index a75647f..0e33d08 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -46,7 +46,9 @@ Option discount = // a later from Create one with `Option.Some(value)` and `Option.None()`, or lift a nullable with `ToOption()`. `GetValueOrNone(key)` looks a key up in a dictionary, and `FirstOrNone()` and `LastOrNone()` take -the end of a sequence, with or without a predicate. +the end of a sequence, with or without a predicate. On a query, `FirstOrNone` and `FirstOrError` ask +the provider for one element and let it run the predicate (a `WHERE` and a `LIMIT` in SQL), and `LastOrNone` is a +compile error, since a query has no last element until it is ordered. `Option.None()` takes its type from where it goes (`return Option.None();`, a conditional beside `Some`); where nothing supplies one, as with `var`, write `Option.None()`. `Some(null)` throws, so an option that reports a value always has one. A `default(Option)` is diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 0d2ebcc..3074419 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -863,6 +863,31 @@ error is null : source.LastOrNone(predicate).ToResult(error); } + /// The query. + /// The type of the elements. + /// The type of the error. + /// The provider runs the condition and is asked for one element, as Option.FirstOrNone on a query is. + extension(IQueryable source) where T : notnull where TError : notnull + { + /// The first element that matches, or the given error: the provider runs the condition, and is asked for one. + /// The condition to match, translated by the query provider. + /// The error if nothing matches. Never null. + /// The first matching element, or . + /// is null. + public Result FirstOrError(System.Linq.Expressions.Expression> predicate, TError error) => + error is null + ? throw ThrowHelper.NullContent(nameof(error)) + : source.FirstOrNone(predicate).ToResult(error); + + /// Refused: a query has no last element until it is ordered, and this would read every row to find one. + /// The condition to match. + /// The error if nothing matches. + /// Never: the overload does not compile. + [Obsolete("A query has no last element until it is ordered, and LastOrError would read every row to find one. Order it the other way and use FirstOrError, or call AsEnumerable() to read it all on purpose.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public Result LastOrError(System.Linq.Expressions.Expression> predicate, TError error) => throw new NotSupportedException(); + } + /// The sequence. /// The type of the elements. extension(IEnumerable source) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs index dcebc3a..eab8725 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs @@ -65,6 +65,8 @@ private static AnalyzerTest TestWithEntityFramewor private const string NullableFirst = "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"; + private const string FirstOrNoneAsync = "Use FirstOrNoneAsync, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => (Plain?)x)"; + private const string MissingKey = " for a missing key"; private const string GetValueOrNone = "Use GetValueOrNone, which returns an Option, or pass a default value"; @@ -125,7 +127,10 @@ public static void Run(List ids) Cmtk0009(9, "FirstOrDefault", "Plain", NothingFound, FirstOrNone)) .RunAsync(TestContext.Current.CancellationToken); - /// In a query, FirstOrNone would run it on the client; a nullable projection is translated. + /// + /// In a query, FirstOrNone asks the provider for one element, as Skip(index).FirstOrNone() + /// does; the rest have no query form beside the nullable projection, which every provider translates. + /// [Fact] public Task Every_query_form_without_a_default_value_reports() => Test( @@ -142,10 +147,10 @@ public static void Run(IQueryable ids) } } """, - Cmtk0009(0, "FirstOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), + Cmtk0009(0, "FirstOrDefault", "Plain", NothingFound, "Use FirstOrNone, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => (Plain?)x)"), Cmtk0009(1, "LastOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), Cmtk0009(2, "SingleOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), - Cmtk0009(3, "ElementAtOrDefault", "Plain", " for an index past the end", "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), + Cmtk0009(3, "ElementAtOrDefault", "Plain", " for an index past the end", "Use Skip(index).FirstOrNone(), which returns an Option and asks the query for one element"), Cmtk0009(4, "DefaultIfEmpty", "Plain", " for an empty sequence", "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null")) .RunAsync(TestContext.Current.CancellationToken); @@ -328,16 +333,32 @@ public static async Task Run(IQueryable ids, System.Threading.Cancellatio } } """, - Cmtk0009(0, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst), - Cmtk0009(1, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst), + Cmtk0009(0, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync), + Cmtk0009(1, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync), Cmtk0009(2, "LastOrDefaultAsync", "Plain", NothingFound, NullableFirst), Cmtk0009(3, "LastOrDefaultAsync", "Plain", NothingFound, NullableFirst), Cmtk0009(4, "SingleOrDefaultAsync", "Plain", NothingFound, NullableFirst), Cmtk0009(5, "SingleOrDefaultAsync", "Plain", NothingFound, NullableFirst), Cmtk0009(6, "ElementAtOrDefaultAsync", "Plain", PastTheEnd, NullableFirst), - Cmtk0009(7, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst)) + Cmtk0009(7, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync)) .RunAsync(TestContext.Current.CancellationToken); + /// FirstOrNoneAsync is the EF Core satellite's: a project that references EF Core without it is told the nullable projection. + [Fact] + public Task Without_the_satellite_FirstOrDefaultAsync_is_told_the_nullable_projection() + { + var test = new AnalyzerTest("using Microsoft.EntityFrameworkCore;" + Environment.NewLine + Subjects + + """ + public static class C + { + public static async Task Run(IQueryable ids) { var first = await {|#0:ids.FirstOrDefaultAsync()|}; } + } + """); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions).Assembly.Location)); + test.ExpectedDiagnostics.Add(Cmtk0009(0, "FirstOrDefaultAsync", "Plain", NothingFound, NullableFirst)); + return test.RunAsync(TestContext.Current.CancellationToken); + } + /// /// The new forms keep the rule's line: an overload given a default value, and an element type whose /// default is null or an ordinary value, stay silent. diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs new file mode 100644 index 0000000..aad8d31 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// FirstOrNone, FirstOrError and FirstOrNoneAsync on a query ask the database +/// for one row and let it run the condition. On the IEnumerable overloads, which were the only +/// ones, db.Shipments.FirstOrNone(s => …) read the whole table and ran the condition in +/// memory, with no warning. +/// +public sealed class QueryOptionTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + private readonly List _commands = []; + + public QueryOptionTests() + { + _connection.Open(); + using var db = Db(); + db.Database.EnsureCreated(); + db.Shipments.AddRange(new Shipment { Carrier = "alpha" }, new Shipment { Carrier = "beta" }, new Shipment { Carrier = "gamma" }); + db.SaveChanges(); + _commands.Clear(); + } + + public void Dispose() => _connection.Dispose(); + + private TestDb Db() => + new(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit() + .LogTo(_commands.Add, [RelationalEventId.CommandExecuted]).Options); + + /// EF passes the limit as a parameter, LIMIT @p, with 1 for its value. + [Fact] + public void FirstOrNone_runs_the_condition_in_the_query_and_reads_one_row() + { + using var db = Db(); + + db.Shipments.FirstOrNone(s => s.Carrier == "beta").TryGetValue(out var found).ShouldBeTrue(); + + found.Carrier.ShouldBe("beta"); + var sql = _commands.ShouldHaveSingleItem(); + sql.ShouldContain("WHERE \"s\".\"Carrier\" = 'beta'"); + sql.ShouldContain("LIMIT @p"); + } + + [Fact] + public void FirstOrNone_without_a_condition_reads_one_row_and_None_reads_none() + { + using var db = Db(); + + db.Shipments.OrderBy(s => s.Carrier).FirstOrNone().TryGetValue(out var first).ShouldBeTrue(); + first.Carrier.ShouldBe("alpha"); + _commands.ShouldHaveSingleItem().ShouldContain("LIMIT @p"); + + db.Shipments.FirstOrNone(s => s.Carrier == "delta").TryGetValue(out _).ShouldBeFalse(); + } + + /// A condition only the database can run, which in memory throws: proof that it is not run there. + [Fact] + public async Task A_condition_only_the_database_can_run_is_translated() + { + await using var db = Db(); + + db.Shipments.FirstOrNone(s => EF.Functions.Like(s.Carrier, "g%")).TryGetValue(out var sync).ShouldBeTrue(); + sync.Carrier.ShouldBe("gamma"); + + (await db.Shipments.FirstOrNoneAsync(s => EF.Functions.Like(s.Carrier, "b%"), TestContext.Current.CancellationToken)).TryGetValue(out var async).ShouldBeTrue(); + async.Carrier.ShouldBe("beta"); + + db.Shipments.FirstOrError(s => EF.Functions.Like(s.Carrier, "a%"), "none").TryGetValue(out var result, out _).ShouldBeTrue(); + result.Carrier.ShouldBe("alpha"); + } + + [Fact] + public async Task FirstOrNoneAsync_reads_one_row_and_answers_None_for_nothing_found() + { + await using var db = Db(); + + (await db.Shipments.FirstOrNoneAsync(s => s.Carrier == "gamma", TestContext.Current.CancellationToken)).TryGetValue(out var found).ShouldBeTrue(); + found.Carrier.ShouldBe("gamma"); + _commands.ShouldHaveSingleItem().ShouldContain("LIMIT @p"); + + (await db.Shipments.Where(s => s.Carrier == "delta").FirstOrNoneAsync(TestContext.Current.CancellationToken)).TryGetValue(out _).ShouldBeFalse(); + db.Shipments.FirstOrError(s => s.Carrier == "delta", "none").TryGetValue(out _, out var error).ShouldBeFalse(); + error.ShouldBe("none"); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs index 612d1f5..4015a95 100644 --- a/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/EntityFrameworkCore/EntityFrameworkCoreSamples.cs @@ -13,6 +13,8 @@ namespace ReadmeSamples.EfCore; public enum CodeFault { Blank } +public enum OrderFault { NotFound } + public readonly partial record struct ProductCode : IValidatedValue { public static Result Create(string value) => @@ -137,6 +139,17 @@ public static void Query(ShopDb db, OrderId id, List ids, Tag tag) // end sample } + public static async Task<(Option Order, Option Earliest, Result Found)> FindOne(ShopDb db, OrderId id) + { + // sample: CodoMetis.TypeKit.EntityFrameworkCore/first-or-none + Option order = await db.Orders.FirstOrNoneAsync(o => o.Id == id); // the database runs the condition and returns one row + Option earliest = db.Orders.OrderBy(o => o.PlacedAt).FirstOrNone(); // the same, synchronous + Result found = db.Orders.FirstOrError(o => o.Id == id, OrderFault.NotFound); + // end sample + + return (order, earliest, found); + } + public static async Task FindRefused(ShopDb db, IRefusalLog log) { // sample: CodoMetis.TypeKit.EntityFrameworkCore/revalidate diff --git a/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs b/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs new file mode 100644 index 0000000..8729e10 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs @@ -0,0 +1,89 @@ +using Microsoft.CodeAnalysis; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// FirstOrNone and FirstOrError on an hand the condition and +/// the limit to the query provider. On the overloads, the only ones there +/// were, a query was read in full and its condition run in memory; the EF Core tests show the SQL. +/// +public sealed class QueryableOptionTests +{ + private static string Consumer(string member) => + $$""" + using System; + using System.Linq; + using CodoMetis.TypeKit; + + static class Consumer + { + static {{member}} + } + """; + + public static TheoryData Calls => new() + { + { "Option First(IQueryable query) => query.FirstOrNone(x => x > 1);", "Option" }, + { "Option First(IQueryable query) => query.FirstOrNone();", "Option" }, + { "Result First(IQueryable query) => query.FirstOrError(x => x > 1, \"none\");", "Result" }, + { "Option First(IOrderedQueryable query) => query.FirstOrNone(x => x > 1);", "Option" }, + }; + + [Theory] + [MemberData(nameof(Calls))] + public void A_query_binds_to_the_overload_that_takes_an_expression(string member, string declaringClass) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + compilation.Problems().ShouldBeEmpty(); + + var (_, method) = compilation.TheOnlyCall(); + + var block = method.ContainingType.ShouldNotBeNull(); + block.IsExtension.ShouldBeTrue(); + block.ContainingType.Name.ShouldBe(declaringClass); + block.ExtensionParameter.ShouldNotBeNull().Type.OriginalDefinition.ToDisplayString().ShouldBe("System.Linq.IQueryable"); + method.Parameters.Where(parameter => parameter.Name == "predicate") + .ShouldAllBe(parameter => parameter.Type.OriginalDefinition.ToDisplayString() == "System.Linq.Expressions.Expression"); + } + + /// A query has no last element until it is ordered; the overloads that would read every row are compile errors that name what to do instead. + [Theory] + [InlineData("Option Last(IQueryable query) => query.LastOrNone();", "FirstOrNone")] + [InlineData("Option Last(IQueryable query) => query.LastOrNone(x => x > 1);", "FirstOrNone")] + [InlineData("Result Last(IQueryable query) => query.LastOrError(x => x > 1, \"none\");", "FirstOrError")] + public void LastOrNone_and_LastOrError_on_a_query_are_compile_errors(string member, string advice) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + + compilation.ErrorIds().ShouldBe(["CS0619"], compilation.Problems()); + compilation.Problems().ShouldContain(advice); + compilation.Problems().ShouldContain("AsEnumerable()"); + } + + [Fact] + public void Reading_a_query_on_purpose_still_has_LastOrNone() => + ConsumerCompilation.Of(Consumer("Option Last(IQueryable query) => query.AsEnumerable().LastOrNone();")).Problems().ShouldBeEmpty(); + + [Fact] + public void A_query_answers_its_first_match_or_None() + { + IQueryable query = new[] { 3, 1, 4, 1, 5 }.AsQueryable(); + + query.FirstOrNone().ShouldBe(Option.Some(3)); + query.FirstOrNone(x => x > 3).ShouldBe(Option.Some(4)); + query.FirstOrNone(x => x > 9).ShouldBe(Option.None()); + query.FirstOrError(x => x > 3, "none").ShouldBe(Result.Success(4)); + query.FirstOrError(x => x > 9, "none").ShouldBe(Result.Error("none")); + Array.Empty().AsQueryable().FirstOrNone().ShouldBe(Option.None()); + } + + [Fact] + public void A_null_first_element_a_null_condition_and_a_null_error_are_refused() + { + var query = new[] { (string)null! }.AsQueryable(); + + Should.Throw(() => query.FirstOrNone()); + Should.Throw(() => new[] { "a" }.AsQueryable().FirstOrNone(null!)).ParamName.ShouldBe("predicate"); + Should.Throw(() => new[] { "a" }.AsQueryable().FirstOrError(x => x == "b", (string)null!)).ParamName.ShouldBe("error"); + } +} From 9a00d2cc6728d6177f32814b67f85df1b08bae85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 14:23:45 +0200 Subject: [PATCH 021/147] feat: a closed set parses through IParsable and binds from configuration A closed set had no type converter, so the reflection-based configuration binder, MVC and anything else reading a string through TypeDescriptor left a set property null with no error; and no IParsable, so generic code parsing through T.Parse could not take one. The generators implement IParsable explicitly, beside the set's own Parse(string) and TryParse(string, out), since a provider means nothing to a name, and add a {Set}TypeConverter that reads with Parse (a refusal names the set, never the input) and writes the name. A hand-written IParsable, [JsonConverter] or [TypeConverter] on a set is CMTK1011, where each failed inside the generated code; the converter's name joins CMTK1021's. Tested through TypeDescriptor, through T.Parse, and with the real configuration binder, a list of members included. Seeded: without the attribute the converter and binding tests fail; without each CMTK1011 check its build-outcome cases fail. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ docs/plan.md | 4 +- .../ClosedSetAspect.Templates.cs | 63 +++++++++++++++++++ .../ClosedSetAspect.cs | 37 ++++++++++- .../ClosedSetTypes.cs | 15 ++++- src/CodoMetis.TypeKit.Generators/README.md | 9 ++- .../ClosedSets/IClosedSet.cs | 6 +- .../ClosedSetBindingTests.cs | 43 +++++++++++++ .../BuildOutcomeTests.cs | 22 +++++++ .../ClosedSetTests.cs | 44 +++++++++++++ .../GeneratedSurface.verified.txt | 18 ++++++ 11 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetBindingTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d57e2d..f9c5571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,11 @@ Semantic Versioning reserves for a major, listed under Changed as they land. `FirstOrNoneAsync`, with or without a predicate, in EF Core's namespace; CMTK0009 names it for `FirstOrDefaultAsync` where the project references the satellite, and names `FirstOrNone` for `Queryable.FirstOrDefault`. +- **`CodoMetis.TypeKit.Generators`**: a closed set implements `IParsable` explicitly, beside + its own `Parse` and `TryParse`, and carries a type converter that reads and writes the member's + name, so the configuration binder binds a set where it bound null without a word, and generic + code parses one through `T.Parse`. A hand-written `IParsable`, `[JsonConverter]` or + `[TypeConverter]` is CMTK1011, where it failed inside the generated code. ### Changed diff --git a/docs/plan.md b/docs/plan.md index 8f43f54..0f6b573 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -601,7 +601,9 @@ Each phase ends green, and its guards have been proven by seeding the defect 7a. **The research picks** (2026-10-13): a research agent reviewed what 2.0.0 lacks against the peers and the issue trackers, and the user took its top five, each a commit: `FirstOrNone`/`FirstOrError` on a query and EF's `FirstOrNoneAsync` ✅ (decision 47); a closed - set's `[TypeConverter]` and `IParsable`; Serilog for closed sets; continuations of a + set's `[TypeConverter]` and `IParsable` ✅ (explicit, since `Parse(string)` is its surface; the + reflection binder now binds a set; the binder's source generator reads no type converter, + dotnet/runtime#83599); Serilog for closed sets; continuations of a `Task>`; `Transpose`, `SingleOrNone` and `ElementAtOrNone`. 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs index ef3fd10..3e962e8 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -1,6 +1,9 @@ +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Json; using Metalama.Framework.Aspects; +using Metalama.Framework.Code.SyntaxBuilders; namespace CodoMetis.TypeKit.Generators; @@ -87,6 +90,66 @@ public static bool TryParseTemplate([NotNullWhen(true)] string? name, [NotNullWh throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); } + /// IParsable<TSelf>.Parse: the set's own Parse; the provider means nothing to a name. + [Template] + public static dynamic? ParsableParseTemplate(string s, IFormatProvider? provider) + { + ArgumentNullException.ThrowIfNull(s); + return meta.ThisType.Parse(s); + } + + /// IParsable<TSelf>.TryParse: the member of that name, compared ordinally, as the set's own TryParse. + [Template] + public static bool ParsableTryParseTemplate([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out dynamic result) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + foreach (var candidate in args.Members) + { + if (s == candidate.Name) + { + result = candidate.Value!; + return true; + } + } + + result = null!; + return false; + } + + [Template] + public bool CanConvertFromTemplate(ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || meta.Base.CanConvertFrom(context, sourceType); + + [Template] + public bool CanConvertToTemplate(ITypeDescriptorContext? context, Type? destinationType) => + destinationType == typeof(string) || meta.Base.CanConvertTo(context, destinationType); + + /// A name is read with the set's own Parse: a naming the set, never the input. + [Template] + public object? ConvertFromTemplate(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is string s) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return ExpressionFactory.Parse($"{ValueObjectTypes.SourceName(args.Set)}.Parse(s)").Value; + } + + return meta.Base.ConvertFrom(context, culture, value); + } + + /// A member is written as its name, which throws for an instance that is no member. + [Template] + public object? ConvertToTemplate(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) + { + if (destinationType == typeof(string) && value is not null) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return ExpressionFactory.Parse($"(({ValueObjectTypes.SourceName(args.Set)})value).Name").Value; + } + + return meta.Base.ConvertTo(context, culture, value, destinationType); + } + /// /// Every delegate is checked before a branch is picked, as Option and Result check /// theirs: a null for a member not matched passed until that member first arrived. diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index 2b8e230..1388456 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Text.Json.Serialization; using CodoMetis.TypeKit.CompilerServices; using Metalama.Framework.Advising; @@ -20,8 +21,9 @@ internal sealed class ClosedSetArguments /// /// Generates a closed set: Name, QualifiedName, All, Match, TryParse, -/// Parse, ToString() where none is written by hand, the JSON converter, the discovery -/// attribute, and a private constructor where the type declares none. Applied by . +/// Parse, IParsable<TSelf> explicitly, ToString() where none is written by +/// hand, the JSON converter, the type converter, the discovery attribute, and a private constructor +/// where the type declares none. Applied by . /// /// /// @@ -143,6 +145,22 @@ public override void BuildAspect(IAspectBuilder builder) m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + // IParsable, explicitly: Parse(string) and TryParse(string, out) are the set's own + // surface, and the provider means nothing to a name. Generic code calling T.Parse reaches it. + var parsable = builder.ImplementInterface(TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(target)); + parsable.ExplicitMembers.IntroduceMethod(nameof(ParsableParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.ReturnType = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + parsable.ExplicitMembers.IntroduceMethod(nameof(ParsableTryParseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Parameters[2].Type = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceMethod(nameof(MatchTemplate), IntroductionScope.Instance, OverrideStrategy.Fail, m => { m.Name = "Match"; @@ -165,7 +183,22 @@ public override void BuildAspect(IAspectBuilder builder) converter.IntroduceMethod(nameof(JsonReadAsPropertyNameTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "ReadAsPropertyName"; m.ReturnType = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); converter.IntroduceMethod(nameof(JsonWriteAsPropertyNameTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "WriteAsPropertyName"; m.Parameters[1].Type = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + // The reflection-based configuration binder, MVC and anything else that reads a string through + // TypeDescriptor find a set by its type converter alone; without one a set bound to null. + var typeConverter = builder.IntroduceClass($"{target.Name}TypeConverter", buildType: t => + { + t.Accessibility = Accessibility.Public; + t.BaseType = TypeFactory.GetNamedType(typeof(TypeConverter)); + t.IsStatic = false; + t.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + typeConverter.IntroduceMethod(nameof(CanConvertFromTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.CanConvertFrom); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + typeConverter.IntroduceMethod(nameof(CanConvertToTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.CanConvertTo); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + typeConverter.IntroduceMethod(nameof(ConvertFromTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.ConvertFrom); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + typeConverter.IntroduceMethod(nameof(ConvertToTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.ConvertTo); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(JsonConverterAttribute), constructorArguments: [converter.Target])); + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(TypeConverterAttribute), constructorArguments: [typeConverter.Target])); builder.IntroduceAttribute(AttributeConstruction.Create(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)).WithTypeArguments(target))); builder.IntroduceAttribute(CodeAnnotations.CompilerGenerated); } diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index d444acc..1b2a9d8 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -1,4 +1,7 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.CompilerServices; using Metalama.Framework.Aspects; using Metalama.Framework.Code; @@ -21,7 +24,7 @@ public static IReadOnlyList Members(INamedType type) => /// The names of what the generators introduce into the set itself; nothing else in it may have one (CMTK1011, CMTK1021). public static IReadOnlyList GeneratedNames(INamedType set) => - ["Name", "QualifiedName", "All", "Match", "Parse", "TryParse", "ToString", "_name", "_all", $"{set.Name}JsonConverter"]; + ["Name", "QualifiedName", "All", "Match", "Parse", "TryParse", "ToString", "_name", "_all", $"{set.Name}JsonConverter", $"{set.Name}TypeConverter"]; /// /// The name of 's parameter of Match: its own, with the first letter @@ -141,8 +144,9 @@ private static string WithoutComments(string text) /// /// The members the generators introduce, written by hand: a property, field, event or nested type - /// of a generated name, a method with a generated signature, or an explicit implementation of - /// IClosedSet<TSelf>. Each failed the aspect with a Metalama error naming no fix. An + /// of a generated name, a method with a generated signature, an explicit implementation of + /// IClosedSet<TSelf>, IParsable<TSelf> declared, or an attribute the + /// generators put on the set. Each failed the aspect with a Metalama error naming no fix. An /// overload of another signature is the set's own and is kept, as is ToString(), the seam. A /// member of the set with a generated name is CMTK1021's, which says to rename it. /// @@ -159,6 +163,11 @@ .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && Sam .. 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()}"), + .. 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<>)))) + .Select(attribute => $"the attribute {attribute.Type.Name.Substring(0, attribute.Type.Name.Length - nameof(Attribute).Length)}"), ]; } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 2f7f429..f4887a0 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -228,6 +228,11 @@ position would follow the declaration order and swap branches when two members a analyzer requires every argument named (CMTK0012), and its code fix names them. `Match` checks every delegate for null before it picks one. `Parse` and the JSON converter refuse an unknown name with an exception naming the set, never the input, and a set is a dictionary key in JSON by its name too. +`IParsable` is implemented explicitly, for generic code that parses through `T.Parse`, and a +type converter reads and writes the name, so the configuration binder, MVC and anything else that +reads a string through `TypeDescriptor` bind a set. The binder's source generator reads no type +converter ([dotnet/runtime#83599](https://github.com/dotnet/runtime/issues/83599)): under it, as under +Native AOT, bind the name as a `string` and `Parse` it. The EF Core satellite maps a closed set to a text column of the member's name, and the ASP.NET Core satellite describes it as a string enum of the names, with nothing registered per set. @@ -331,7 +336,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | | CMTK1009 | A hand-written instance constructor, including a positional record's parameter list such as `OrderId(Guid Value)`. The constructor and `Value` are generated; a static constructor is fine. | | CMTK1010 | Declared in more than one `partial` part: Metalama 2026.1 writes the generated `ToString()` into every part, which does not compile. A part a source generator adds, such as a `[GeneratedRegex]` method's, does not count. | -| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members", and for a closed set every generated member but `ToString()`, by signature), an explicit implementation of an interface they implement, a hand-written equality, a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | +| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members", and for a closed set every generated member but `ToString()`, by signature, `IParsable`, `[JsonConverter]` and `[TypeConverter]`), an explicit implementation of an interface they implement, a hand-written equality, a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | | CMTK1012 | Instance state besides the wrapped value: a field, an auto-property, a `required` member or a field-like event, declared or inherited. Compute it from `Value`, make it static, or wrap a type that holds all of it. | | CMTK1013 | A closed set not declared `partial`. | | CMTK1014 | A closed set not declared `sealed`: a set with subtypes is not closed. | @@ -341,7 +346,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1018 | A closed set that is generic or nested in a generic type. | | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | -| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the converter), or whose `Match` parameter, its name with the first letter lower-cased, another member's already is. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose `Match` parameter, its name with the first letter lower-cased, another member's already is. | The analyzers that come with the base package add CMTK0001 to CMTK0012; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). diff --git a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs index e527f87..c777343 100644 --- a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs +++ b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs @@ -12,8 +12,10 @@ namespace CodoMetis.TypeKit.ClosedSets; /// CodoMetis.TypeKit.Generators generates everything else: from the field's name, /// (OrderStatus.Open), in declaration order, /// Match with one required delegate per member named after it, Parse and -/// by name, ToString(), a JSON converter that writes the name and reads -/// the member back, and a private parameterless constructor where the type declares none. A member +/// by name, explicitly, ToString(), a JSON +/// converter that writes the name and reads the member back, a type converter for what reads a string +/// through TypeDescriptor (the configuration binder, MVC), and a private parameterless +/// constructor where the type declares none. A member /// may carry constant data through a private constructor the type declares, as /// new(FaultKind.NotFound, "The order does not exist."). /// diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetBindingTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetBindingTests.cs new file mode 100644 index 0000000..87fcc1b --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetBindingTests.cs @@ -0,0 +1,43 @@ +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.Extensions.Configuration; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A closed set in configuration: the reflection-based binder reads it through its type converter. +/// Without one, a set property bound to null with no error, the setting silently ignored. +/// +public sealed class ClosedSetBindingTests +{ + public sealed class Settings + { + public ProbeStatus? Status { get; set; } + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + + public List Allowed { get; set; } = []; + } + + private static IConfiguration Configuration(params (string Key, string Value)[] values) => + new ConfigurationBuilder().AddInMemoryCollection(values.Select(value => KeyValuePair.Create(value.Key, (string?)value.Value))).Build(); + + [Fact] + public void A_set_binds_by_its_member_s_name() + { + var settings = Configuration(("Status", "Shipped"), ("Tier", "Premium"), ("Allowed:0", "Open"), ("Allowed:1", "Cancelled")).Get()!; + + ReferenceEquals(settings.Status, ProbeStatus.Shipped).ShouldBeTrue(); + ReferenceEquals(settings.Tier, ProbeTier.Premium).ShouldBeTrue(); + settings.Allowed.ShouldBe([ProbeStatus.Open, ProbeStatus.Cancelled]); + } + + /// An unknown name fails the binding, as it fails Parse, and the message does not quote the setting. + [Fact] + public void An_unknown_name_fails_the_binding_without_quoting_it() + { + var refusal = Should.Throw(() => Configuration(("Status", "Nope")).Get()); + + refusal.InnerException.ShouldBeOfType().Message.ShouldContain("ProbeStatus"); + refusal.InnerException!.Message.ShouldNotContain("Nope"); + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 978d52f..94e5b43 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -119,6 +119,9 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1020", "SetRecord")] [InlineData("CMTK1011", "SetWithName")] [InlineData("CMTK1011", "SetWithExplicitAll")] + [InlineData("CMTK1011", "SetWithParsable")] + [InlineData("CMTK1011", "SetWithTypeConverterAttribute")] + [InlineData("CMTK1011", "SetWithJsonConverterAttribute")] [InlineData("CMTK1021", "SetWithMemberNamedParse.Parse")] [InlineData("CMTK1021", "SetWithMemberNamedToString.ToString")] [InlineData("CMTK1021", "SetWithMemberNamedAll.All")] @@ -138,6 +141,17 @@ public void A_declaration_that_cannot_be_generated_is_an_error(string id, string public void A_member_name_the_generators_take_is_refused_with_the_reason(string member, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1021" && error.Message.StartsWith($"'{member}' {reason}, so ") && error.Message.Contains("rename the member"), consumer.Output); + /// + /// What the generators implement and attach, written by hand, failed the aspect or the generated + /// code: IParsable twice, an attribute twice (CS0579). The error names each. + /// + [Theory] + [InlineData("SetWithParsable", "the interface IParsable")] + [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); + [Fact] public void A_member_is_never_told_to_be_removed_as_a_hand_written_generated_member() => consumer.Errors.ShouldNotContain(error => error.Id == "CMTK1011" && error.Message.Contains("SetWithMemberNamed"), consumer.Output); @@ -462,6 +476,14 @@ public sealed partial class SetWithoutMembers : IClosedSet { public sealed partial class SetWithExplicitAll : IClosedSet { public static readonly SetWithExplicitAll A = new(); static System.Collections.Generic.IReadOnlyList IClosedSet.All => [A]; } + public sealed partial class SetWithParsable : IClosedSet, System.IParsable { public static readonly SetWithParsable A = new(); } + + [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.StringConverter))] + public sealed partial class SetWithTypeConverterAttribute : IClosedSet { public static readonly SetWithTypeConverterAttribute A = new(); } + + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public sealed partial class SetWithJsonConverterAttribute : IClosedSet { public static readonly SetWithJsonConverterAttribute A = new(); } + public sealed partial class SetWithMemberNamedParse : IClosedSet { public static readonly SetWithMemberNamedParse A = new(); public static readonly SetWithMemberNamedParse Parse = new(); } public sealed partial class SetWithMemberNamedToString : IClosedSet { public static readonly new SetWithMemberNamedToString ToString = new(); } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index fee2bdb..7dcd17d 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Text.Json; using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.CompilerServices; @@ -225,6 +226,49 @@ public void A_member_whose_parameter_is_a_keyword_is_matched_by_the_escaped_name typeof(ProbeKeyword).GetMethod("Match")!.GetParameters().Select(parameter => parameter.Name).ShouldBe(["new", "default"]); } + /// Generic code that parses through reaches a set, implemented explicitly beside its own Parse. + [Fact] + public void Generic_code_parses_a_set_through_IParsable() + { + ReferenceEquals(ParseAs("Shipped"), ProbeStatus.Shipped).ShouldBeTrue(); + TryParseAs("Cancelled", out var cancelled).ShouldBeTrue(); + ReferenceEquals(cancelled, ProbeStatus.Cancelled).ShouldBeTrue(); + TryParseAs("shipped", out _).ShouldBeFalse("names compare ordinally"); + TryParseAs(null, out _).ShouldBeFalse(); + + var refusal = Should.Throw(() => ParseAs("Nope")); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("Nope"); + Should.Throw(() => ParseAs(null!)); + + typeof(ProbeStatus).GetMethods().Where(method => method.Name == "Parse").ShouldHaveSingleItem("the provider overload is explicit"); + } + + /// + /// What reads a string through , the reflection-based configuration + /// binder and MVC among them, finds a set by its type converter alone: without one, a set bound to + /// null with no error. + /// + [Fact] + public void The_type_converter_reads_and_writes_the_name() + { + var converter = TypeDescriptor.GetConverter(typeof(ProbeStatus)); + + converter.CanConvertFrom(typeof(string)).ShouldBeTrue(); + converter.CanConvertTo(typeof(string)).ShouldBeTrue(); + ReferenceEquals(converter.ConvertFromInvariantString("Shipped"), ProbeStatus.Shipped).ShouldBeTrue(); + converter.ConvertToInvariantString(ProbeStatus.Cancelled).ShouldBe("Cancelled"); + + var refusal = Should.Throw(() => converter.ConvertFromInvariantString("Nope")); + refusal.Message.ShouldNotContain("Nope"); + Should.Throw(() => TypeDescriptor.GetConverter(typeof(ProbeLevel)).ConvertToInvariantString(ProbeLevel.Unlisted())) + .Message.ShouldEndWith("#an-instance-that-is-not-a-member-of-its-set"); + } + + private static T ParseAs(string s) where T : IParsable => T.Parse(s, null); + + private static bool TryParseAs(string? s, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out T result) where T : IParsable => T.TryParse(s, null, out result); + private static string Names() where TSet : class, IClosedSet => string.Join(",", TSet.All.Select(member => member.Name)); private sealed class Counter : IClosedSetVisitor diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index f65fc86..f9f706a 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -612,8 +612,11 @@ ProbeKeyword attribute CompilerGeneratedAttribute attribute GeneratedClosedSetAttribute attribute JsonConverterAttribute + attribute TypeConverterAttribute implements IClosedSet + implements IParsable nested ProbeKeywordJsonConverter : JsonConverter + nested ProbeKeywordTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeKeyword static TryParse(String, out ProbeKeyword) : Boolean @@ -664,8 +667,11 @@ ProbeLabelled attribute CompilerGeneratedAttribute attribute GeneratedClosedSetAttribute attribute JsonConverterAttribute + attribute TypeConverterAttribute implements IClosedSet + implements IParsable nested ProbeLabelledJsonConverter : JsonConverter + nested ProbeLabelledTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeLabelled static TryParse(String, out ProbeLabelled) : Boolean @@ -677,8 +683,11 @@ ProbeLevel attribute CompilerGeneratedAttribute attribute GeneratedClosedSetAttribute attribute JsonConverterAttribute + attribute TypeConverterAttribute implements IClosedSet + implements IParsable nested ProbeLevelJsonConverter : JsonConverter + nested ProbeLevelTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeLevel static TryParse(String, out ProbeLevel) : Boolean @@ -957,8 +966,11 @@ ProbePriority attribute CompilerGeneratedAttribute attribute GeneratedClosedSetAttribute attribute JsonConverterAttribute + attribute TypeConverterAttribute implements IClosedSet + implements IParsable nested ProbePriorityJsonConverter : JsonConverter + nested ProbePriorityTypeConverter : TypeConverter static All : IReadOnlyList static Parse(Int32) : ProbePriority static Parse(String) : ProbePriority @@ -1118,8 +1130,11 @@ ProbeStatus attribute CompilerGeneratedAttribute attribute GeneratedClosedSetAttribute attribute JsonConverterAttribute + attribute TypeConverterAttribute implements IClosedSet + implements IParsable nested ProbeStatusJsonConverter : JsonConverter + nested ProbeStatusTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeStatus static TryParse(String, out ProbeStatus) : Boolean @@ -1133,8 +1148,11 @@ ProbeTier attribute CompilerGeneratedAttribute attribute GeneratedClosedSetAttribute attribute JsonConverterAttribute + attribute TypeConverterAttribute implements IClosedSet + implements IParsable nested ProbeTierJsonConverter : JsonConverter + nested ProbeTierTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeTier static TryParse(String, out ProbeTier) : Boolean From fbb7091a9ee56f3a06811b6ce7e7be89f4bff0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 14:25:53 +0200 Subject: [PATCH 022/147] fix: Serilog logs a closed set as what it prints, not by its properties Destructure.WithTypeKit() recognised value objects only, so {@Tier} and every closed set inside a destructured record were logged by their public properties: ProbeTier { Rank: 2, Perk: "Priority support.", Name: "Premium", QualifiedName: ... }, the set's constant data in the log and a hand-written ToString() bypassed (measured). The policy reads the GeneratedClosedSetAttribute beside GeneratedValueObjectAttribute, so a set is a scalar of its ToString(): its name, or its own text. An instance that is no member has no name; Serilog catches the ToString() refusal and logs that it could not capture the value, pinned by a test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ docs/plan.md | 3 +- .../CodoMetis.TypeKit.Serilog.csproj | 2 +- src/CodoMetis.TypeKit.Serilog/README.md | 11 ++++--- .../ValueObjectDestructuringPolicy.cs | 22 +++++++------ .../DestructuringTests.cs | 32 +++++++++++++++++++ 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c5571..1695a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,9 @@ Semantic Versioning reserves for a major, listed under Changed as they land. name, so the configuration binder binds a set where it bound null without a word, and generic code parses one through `T.Parse`. A hand-written `IParsable`, `[JsonConverter]` or `[TypeConverter]` is CMTK1011, where it failed inside the generated code. +- **`CodoMetis.TypeKit.Serilog`**: `Destructure.WithTypeKit()` logs a closed set as what it prints, + its name or its own `ToString()`, where Serilog destructured it by its properties and logged its + constant data, `{ Rank: 2, Perk: "…", Name: "Premium", QualifiedName: … }`. ### Changed diff --git a/docs/plan.md b/docs/plan.md index 0f6b573..1e7ba82 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -603,7 +603,8 @@ Each phase ends green, and its guards have been proven by seeding the defect `FirstOrNone`/`FirstOrError` on a query and EF's `FirstOrNoneAsync` ✅ (decision 47); a closed set's `[TypeConverter]` and `IParsable` ✅ (explicit, since `Parse(string)` is its surface; the reflection binder now binds a set; the binder's source generator reads no type converter, - dotnet/runtime#83599); Serilog for closed sets; continuations of a + dotnet/runtime#83599); Serilog for closed sets ✅ (measured first: `{@Tier}` logged + `ProbeTier { Rank: 2, Perk: "Priority support.", … }`); continuations of a `Task>`; `Transpose`, `SingleOrNone` and `ElementAtOrNone`. 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. diff --git a/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj b/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj index 1285d6b..2a36425 100644 --- a/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj +++ b/src/CodoMetis.TypeKit.Serilog/CodoMetis.TypeKit.Serilog.csproj @@ -2,7 +2,7 @@ - Serilog integration for CodoMetis.TypeKit: value objects are logged as what they print, so a destructured value object or a record holding one never exposes a value its ToString() masks. + Serilog integration for CodoMetis.TypeKit: value objects and closed sets are logged as what they print, so a destructured value object or a record holding one never exposes a value its ToString() masks. serilog;logging;structured-logging;value-object;strongly-typed-id;ddd;redaction diff --git a/src/CodoMetis.TypeKit.Serilog/README.md b/src/CodoMetis.TypeKit.Serilog/README.md index f1ab3c2..2406000 100644 --- a/src/CodoMetis.TypeKit.Serilog/README.md +++ b/src/CodoMetis.TypeKit.Serilog/README.md @@ -1,7 +1,7 @@ # CodoMetis.TypeKit.Serilog Serilog integration for [CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit) value -objects: each is logged as what it prints, also where Serilog destructures it. +objects and closed sets: each is logged as what it prints, also where Serilog destructures it. ```bash dotnet add package CodoMetis.TypeKit.Serilog @@ -25,9 +25,12 @@ logger.Information("Created {@Client}", new Client(ApiKey.From("k-123"), "Ann")) - **Every value object**, masked or not, is then a string in the event: an `OrderId` is `"0199aaaa-…"`, not `{ Value: "0199aaaa-…" }`, and a `Quantity` is `"3"`. A list or dictionary of them is a list or dictionary of strings. -- **Nothing per type.** A value object is recognised by the attribute the generators put on it, so - the host needs this package and the base package, not the generators. Other types are left to - Serilog. +- **A closed set** is logged as its name, or as what its own `ToString()` prints, never by its + properties: `{@Order}` holds `Status: "Shipped"`, not the set's constant data. An instance the + set's own code made beside its members has no name, and Serilog logs that it could not capture it. +- **Nothing per type.** A value object or a closed set is recognised by the attribute the generators + put on it, so the host needs this package and the base package, not the generators. Other types + are left to Serilog. - To mask a value object, declare `ToString()`; the [Generators README](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#a-value-that-must-not-be-printed) says what else then still carries the value. diff --git a/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs b/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs index 16c82d2..cf8dc01 100644 --- a/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs +++ b/src/CodoMetis.TypeKit.Serilog/ValueObjectDestructuringPolicy.cs @@ -8,8 +8,8 @@ namespace CodoMetis.TypeKit.Serilog; /// -/// Destructures a value object as what it prints: a scalar of its ToString(), never its -/// properties. +/// Destructures a value object or a closed set as what it prints: a scalar of its ToString(), +/// never its properties. /// /// /// @@ -17,25 +17,29 @@ namespace CodoMetis.TypeKit.Serilog; /// { Value: … }, read from the public Value: a value object that masks itself with a /// hand-written ToString() is logged unmasked there, and nowhere else (spikes/SensitiveValues, /// finding 4). With it, Serilog logs a value object as Microsoft.Extensions.Logging does, as its text. +/// A closed set was logged by its properties too, its constant data and its hand-written +/// ToString()'s text bypassed alike; it is its name, or what its own ToString() prints. /// /// -/// Recognised by the every value object carries, never by -/// a name or by , and remembered per type. +/// Recognised by the every value object carries and the +/// every closed set carries, never by a name or by +/// , and remembered per type. /// /// internal sealed class ValueObjectDestructuringPolicy : IDestructuringPolicy { - private static readonly ConcurrentDictionary ValueObjects = new(); + private static readonly ConcurrentDictionary PrintedTypes = new(); public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, [NotNullWhen(true)] out LogEventPropertyValue? result) { - result = IsValueObject(value.GetType()) ? new ScalarValue(value.ToString()) : null; + result = PrintsItself(value.GetType()) ? new ScalarValue(value.ToString()) : null; return result is not null; } /// A boxed nullable arrives as its underlying type, so the type itself is the value object. - private static bool IsValueObject(Type type) => - ValueObjects.GetOrAdd(type, static candidate => - candidate.GetCustomAttribute(inherit: false) is { } valueObject && valueObject.ValueObjectType == candidate); + private static bool PrintsItself(Type type) => + PrintedTypes.GetOrAdd(type, static candidate => + (candidate.GetCustomAttribute(inherit: false) is { } valueObject && valueObject.ValueObjectType == candidate) + || (candidate.GetCustomAttribute(inherit: false) is { } set && set.SetType == candidate)); } diff --git a/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs b/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs index df36bd6..c1ec60f 100644 --- a/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs +++ b/test/CodoMetis.TypeKit.Serilog.Tests/DestructuringTests.cs @@ -70,6 +70,38 @@ public void Scalars_and_other_types_are_left_to_Serilog() Render("{@Plain}", new Plain("Ann", 42)).ShouldBe("Plain { Name: \"Ann\", Age: 42 }"); } + public sealed record Order(ProbeStatus Status, ProbeTier Tier); + + /// + /// A closed set is logged as what it prints, its name, as Microsoft.Extensions.Logging logs it. + /// Destructured by its properties, it was { Rank: 2, Perk: "…", Name: …, QualifiedName: … }: + /// its constant data reached the log through a path the set never chose. + /// + [Fact] + public void A_closed_set_is_logged_as_its_name_when_destructured() + { + Render("{@Tier}", ProbeTier.Premium).ShouldBe("\"Premium\""); + Render("{@Order}", new Order(ProbeStatus.Shipped, ProbeTier.Basic)).ShouldBe("Order { Status: \"Shipped\", Tier: \"Basic\" }"); + Render("{@List}", new List { ProbeStatus.Open, ProbeStatus.Cancelled }).ShouldBe("[\"Open\", \"Cancelled\"]"); + } + + /// A hand-written ToString() is the set's seam, and what it prints is what is logged. + [Fact] + public void A_closed_set_with_its_own_ToString_is_logged_as_what_it_prints() => + Render("{@Level}", ProbeLabelled.High).ShouldBe("\"high priority\""); + + /// + /// An instance that is no member has no name, and its ToString() throws. Serilog catches it + /// and says so in the event: the log shows the defect rather than a null, and the call still logs. + /// + [Fact] + public void An_instance_that_is_no_member_is_logged_as_the_failure_to_capture_it() => + Render("{@Level}", ProbeLevel.Unlisted()).ShouldBe("\"Capturing the property value threw an exception: InvalidOperationException\""); + + [Fact] + public void Without_it_a_destructured_closed_set_shows_its_data() => + Render(configure: null, "{@Tier}", ProbeTier.Premium).ShouldContain("Priority support."); + private static string Render(string template, object? value) => Render(configure: destructure => destructure.WithTypeKit(), template, value); From a5d8e0c5daa6eca6e9719c36b19230031d5105dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 14:30:31 +0200 Subject: [PATCH 023/147] feat: an Option continues asynchronously, and a pending one into a Result pipeline A repository returning Task> broke the README's pipeline at its first step: MapAsync on it failed to infer against the Task overloads (CS0411), and an option had no asynchronous step at all. Option gains MapAsync, BindAsync and TapAsync over callbacks that return a Task, and Task> gains MapAsync, BindAsync (each with a synchronous and a Task overload), FilterAsync, TapAsync and ToResultAsync, the step into a Result pipeline. The shapes are the Task continuations': every delegate, and ToResultAsync's error, is checked before the option is known, and a callback returning a Task is waited for. The README's Option section shows a lookup into a Result, compiled. Seeded: FilterAsync without its delegate check fails the null-delegate case on a faulted task; ToResultAsync checking its error only after the await is cancelled at its timeout; TapAsync not awaiting fails the waited-for test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + docs/plan.md | 3 +- src/CodoMetis.TypeKit/Option.cs | 36 ++++++ src/CodoMetis.TypeKit/OptionExtensions.cs | 105 ++++++++++++++++++ src/CodoMetis.TypeKit/README.md | 13 +++ .../Base/OptionSamples.cs | 21 ++++ .../NullDelegateTests.cs | 18 +++ .../OptionAsyncTests.cs | 92 +++++++++++++++ 8 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 test/CodoMetis.TypeKit.Tests/OptionAsyncTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1695a4f..d74cb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,11 @@ Semantic Versioning reserves for a major, listed under Changed as they land. - **`CodoMetis.TypeKit.Serilog`**: `Destructure.WithTypeKit()` logs a closed set as what it prints, its name or its own `ToString()`, where Serilog destructured it by its properties and logged its constant data, `{ Rank: 2, Perk: "…", Name: "Premium", QualifiedName: … }`. +- **`CodoMetis.TypeKit`**: an option's asynchronous steps, `MapAsync`, `BindAsync` and `TapAsync` over + callbacks that return a `Task`, and the continuations of a `Task>`: `MapAsync`, + `BindAsync`, `FilterAsync`, `TapAsync` and `ToResultAsync`, so a repository's lookup continues into + a `Result` pipeline. Each checks its delegates, and `ToResultAsync` its error, before the option is + known, as the `Task>` continuations do. ### Changed diff --git a/docs/plan.md b/docs/plan.md index 1e7ba82..eb4eb0d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -605,7 +605,8 @@ Each phase ends green, and its guards have been proven by seeding the defect reflection binder now binds a set; the binder's source generator reads no type converter, dotnet/runtime#83599); Serilog for closed sets ✅ (measured first: `{@Tier}` logged `ProbeTier { Rank: 2, Perk: "Priority support.", … }`); continuations of a - `Task>`; `Transpose`, `SingleOrNone` and `ElementAtOrNone`. + `Task>` ✅ (the `Task` set's shapes: no `OrAsync`, no `MatchAsync`, no + `ValueTask` receiver); `Transpose`, `SingleOrNone` and `ElementAtOrNone`. 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict diff --git a/src/CodoMetis.TypeKit/Option.cs b/src/CodoMetis.TypeKit/Option.cs index 168296f..3c836b3 100644 --- a/src/CodoMetis.TypeKit/Option.cs +++ b/src/CodoMetis.TypeKit/Option.cs @@ -108,6 +108,42 @@ public Option Filter(Func predicate) return _hasValue && predicate(_value!) ? this : Option.None(); } + /// Transforms the value, if there is one, with an asynchronous function. + /// Called with the value if there is one. Its task must not produce null. + /// The type of the transformed value. + /// The transformed value, or None without calling . + /// 's task produced null. + public async Task> MapAsync(Func> selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(selector); + + return _hasValue ? Option.Some(await selector(_value!).ConfigureAwait(false)) : Option.None(); + } + + /// Chains an asynchronous operation that may itself produce no value. + /// Called with the value if there is one. + /// The type of the chained option's value. + /// The option produced, or None without calling it. + public async Task> BindAsync(Func>> selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(selector); + + return _hasValue ? await selector(_value!).ConfigureAwait(false) : Option.None(); + } + + /// Runs an asynchronous side effect on the value, if there is one. + /// Called with the value if there is one. + /// This option, unchanged, once has completed. + public async Task> TapAsync(Func action) + { + ArgumentNullException.ThrowIfNull(action); + + if (_hasValue) + await action(_value!).ConfigureAwait(false); + + return this; + } + /// /// Unwraps the value, substituting for None. The siblings for a /// fallback of default or are OrDefault() and OrNull(). diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index 1dcbd3d..a3bee2c 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -272,6 +272,111 @@ Func selector public T? OrNull() => option.TryGetValue(out var value) ? value : null; } + /// An option that is still being produced, such as a repository's lookup. + /// The type of the value. + /// + /// The continuations of Task<Result<…>> for an option: every delegate, and an + /// error for ToResultAsync, is checked before the option is known, as before a branch is + /// picked. A callback that returns a Task is waited for; one that returns a + /// ValueTask needs .AsTask() or an async lambda, as for a result. + /// + extension(Task> task) where T : notnull + { + /// Transforms the value once the option is known, if there is one. + /// Called with the value if there is one. It must not return null. + /// The type of the transformed value. + /// The transformed value, or None without calling . + public async Task> MapAsync(Func selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(selector); + + return (await task.ConfigureAwait(false)).Map(selector); + } + + /// Transforms the value once the option is known, with an asynchronous function. + /// Called with the value if there is one. Its task must not produce null. + /// The type of the transformed value. + /// The transformed value, or None without calling . + public async Task> MapAsync(Func> selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(selector); + + return await (await task.ConfigureAwait(false)).MapAsync(selector).ConfigureAwait(false); + } + + /// Chains an operation that may itself produce no value, once the option is known. + /// Called with the value if there is one. + /// The type of the chained option's value. + /// The option returned, or None without calling it. + public async Task> BindAsync(Func> selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(selector); + + return (await task.ConfigureAwait(false)).Bind(selector); + } + + /// Chains an asynchronous operation that may itself produce no value, once the option is known. + /// Called with the value if there is one. + /// The type of the chained option's value. + /// The option produced, or None without calling it. + public async Task> BindAsync(Func>> selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(selector); + + return await (await task.ConfigureAwait(false)).BindAsync(selector).ConfigureAwait(false); + } + + /// Keeps the value once the option is known, only if it satisfies . + /// Called with the value if there is one. + /// The option if it holds a value that passes, otherwise None. + public async Task> FilterAsync(Func predicate) + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(predicate); + + return (await task.ConfigureAwait(false)).Filter(predicate); + } + + /// Runs a side effect on the value once the option is known, if there is one. + /// Called with the value if there is one. + /// The option, unchanged. + public async Task> TapAsync(Action action) + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(action); + + return (await task.ConfigureAwait(false)).Tap(action); + } + + /// Runs an asynchronous side effect on the value once the option is known, if there is one. + /// Called with the value if there is one. + /// The option, unchanged, once has completed. + public async Task> TapAsync(Func action) + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(action); + + return await (await task.ConfigureAwait(false)).TapAsync(action).ConfigureAwait(false); + } + + /// Turns absence into once the option is known: the step from a lookup into a Result pipeline. + /// The error for None. Never null. + /// The type of the error. + /// A success with the value, or . + /// is null, whether or not the option has a value. + public async Task> ToResultAsync(TError error) where TError : notnull + { + ArgumentNullException.ThrowIfNull(task); + if (error is null) throw ThrowHelper.NullContent(nameof(error)); + + return (await task.ConfigureAwait(false)).ToResult(error); + } + } + /// The sequence. /// The type of the elements. extension(IEnumerable source) where T : notnull diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 0e33d08..c228600 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -57,6 +57,19 @@ compile error, since a query has no last element until it is ordered. absence into an error. `ToString()` never prints the content, so an option is safe to log; the debugger shows it. +A lookup that is still running is a `Task>`, and continues as a pending result does: + +```csharp +Result active = await repository.FindByEmailAsync(email) // Task> + .FilterAsync(c => c.IsActive) + .TapAsync(c => audit.SeenAsync(c)) // waited for + .ToResultAsync(SignInFault.Unknown); // into a Result pipeline +``` + +`MapAsync`, `BindAsync`, `FilterAsync`, `TapAsync` and `ToResultAsync` continue a pending option, and +an option has `MapAsync`, `BindAsync` and `TapAsync` over callbacks that return a `Task`. Each checks +its delegates, and `ToResultAsync` its error, before the option is known. + ## Result<T, TError> and Result<TError> The outcome of an operation: a value or an error of your own type, or for a command that produces diff --git a/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs index 981f5d9..5fd9612 100644 --- a/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Base/OptionSamples.cs @@ -7,8 +7,17 @@ namespace ReadmeSamples.Base.Options; public interface ICustomerRepository { Option FindByEmail(string email); + + Task> FindByEmailAsync(string email); } +public interface IAudit +{ + Task SeenAsync(Customer customer); +} + +public enum SignInFault { Unknown } + public sealed class Customer { public required string Name { get; init; } @@ -53,4 +62,16 @@ from rate in discounts.GetValueOrNone(c.Tier) // TryGetValue as a select rate; // end sample } + + public static async Task> UseAsync(ICustomerRepository repository, IAudit audit, string email) + { + // sample: CodoMetis.TypeKit/option-async + Result active = await repository.FindByEmailAsync(email) // Task> + .FilterAsync(c => c.IsActive) + .TapAsync(c => audit.SeenAsync(c)) // waited for + .ToResultAsync(SignInFault.Unknown); // into a Result pipeline + // end sample + + return active; + } } diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index a0622f9..9162a9a 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -39,6 +39,10 @@ public sealed class NullDelegateTests // A pending result that fails: only a delegate checked before the task is awaited reports itself // rather than the task's own exception. private static Task> PendingFault => Task.FromException>(new TimeoutException()); + + private static Task> PendingSome => Task.FromResult(Some); + private static Task> PendingNone => Task.FromResult(None); + private static Task> PendingOptionFault => Task.FromException>(new TimeoutException()); private static Task> PendingCommandFault => Task.FromException>(new TimeoutException()); private static readonly Dictionary[]> Cases = new() @@ -50,6 +54,20 @@ public sealed class NullDelegateTests ["Option.Tap(Action): action"] = [() => Some.Tap(Null>()), () => None.Tap(Null>())], ["Option.Filter(Func): predicate"] = [() => Some.Filter(Null>()), () => None.Filter(Null>())], + ["Option.MapAsync(Func>): selector"] = [() => Some.MapAsync(Null>>()), () => None.MapAsync(Null>>())], + ["Option.BindAsync(Func>>): selector"] = [() => Some.BindAsync(Null>>>()), () => None.BindAsync(Null>>>())], + ["Option.TapAsync(Func): action"] = [() => Some.TapAsync(Null>()), () => None.TapAsync(Null>())], + + ["Option.MapAsync(Task>, Func): selector"] = [() => PendingSome.MapAsync(Null>()), () => PendingNone.MapAsync(Null>()), () => PendingOptionFault.MapAsync(Null>())], + ["Option.MapAsync(Task>, Func>): selector"] = [() => PendingSome.MapAsync(Null>>()), () => PendingNone.MapAsync(Null>>()), () => PendingOptionFault.MapAsync(Null>>())], + ["Option.BindAsync(Task>, Func>): selector"] = + [() => PendingSome.BindAsync(Null>>()), () => PendingNone.BindAsync(Null>>()), () => PendingOptionFault.BindAsync(Null>>())], + ["Option.BindAsync(Task>, Func>>): selector"] = + [() => PendingSome.BindAsync(Null>>>()), () => PendingNone.BindAsync(Null>>>()), () => PendingOptionFault.BindAsync(Null>>>())], + ["Option.FilterAsync(Task>, Func): predicate"] = [() => PendingSome.FilterAsync(Null>()), () => PendingNone.FilterAsync(Null>()), () => PendingOptionFault.FilterAsync(Null>())], + ["Option.TapAsync(Task>, Action): action"] = [() => PendingSome.TapAsync(Null>()), () => PendingNone.TapAsync(Null>()), () => PendingOptionFault.TapAsync(Null>())], + ["Option.TapAsync(Task>, Func): action"] = [() => PendingSome.TapAsync(Null>()), () => PendingNone.TapAsync(Null>()), () => PendingOptionFault.TapAsync(Null>())], + ["Option.Select(Option, Func): selector"] = [() => Some.Select(Null>()), () => None.Select(Null>())], ["Option.SelectMany(Option, Func>): selector"] = [() => Some.SelectMany(Null>>()), () => None.SelectMany(Null>>())], ["Option.SelectMany(Option, Func>, Func): selector"] = diff --git a/test/CodoMetis.TypeKit.Tests/OptionAsyncTests.cs b/test/CodoMetis.TypeKit.Tests/OptionAsyncTests.cs new file mode 100644 index 0000000..06d9cdc --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/OptionAsyncTests.cs @@ -0,0 +1,92 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// An option's asynchronous steps, and the continuations of a Task<Option<T>>: a +/// repository's lookup continues as a pending result does, where MapAsync on one failed to +/// infer its type arguments against the Result overloads (CS0411) and nothing else existed. +/// +public sealed class OptionAsyncTests +{ + private static Task> PendingSome => Task.FromResult(Option.Some(2)); + private static Task> PendingNone => Task.FromResult(Option.None()); + + private static async Task> LaterAsync(Option option) + { + await Task.Yield(); + return option; + } + + [Fact] + public async Task A_pending_option_maps_binds_and_filters_once_known() + { + (await PendingSome.MapAsync(x => x * 10)).ShouldBe(Option.Some(20)); + (await PendingSome.MapAsync(async x => { await Task.Yield(); return x + 1; })).ShouldBe(Option.Some(3)); + (await PendingSome.BindAsync(x => x > 1 ? Option.Some(x.ToString()) : Option.None())).ShouldBe(Option.Some("2")); + (await PendingSome.BindAsync(x => LaterAsync(Option.Some(x + 5)))).ShouldBe(Option.Some(7)); + (await PendingSome.FilterAsync(x => x > 5)).ShouldBe(Option.None()); + (await LaterAsync(Option.Some(9)).FilterAsync(x => x > 5)).ShouldBe(Option.Some(9)); + } + + [Fact] + public async Task None_passes_through_without_calling_anything() + { + static int Never(int _) => throw new InvalidOperationException("called on None"); + + (await PendingNone.MapAsync(Never)).ShouldBe(Option.None()); + (await PendingNone.MapAsync(x => Task.FromResult(Never(x)))).ShouldBe(Option.None()); + (await PendingNone.BindAsync(x => Option.Some(Never(x)))).ShouldBe(Option.None()); + (await PendingNone.BindAsync(x => Task.FromResult(Option.Some(Never(x))))).ShouldBe(Option.None()); + (await PendingNone.FilterAsync(x => Never(x) > 0)).ShouldBe(Option.None()); + (await PendingNone.TapAsync(x => { Never(x); })).ShouldBe(Option.None()); + (await PendingNone.TapAsync(x => Task.FromResult(Never(x)))).ShouldBe(Option.None()); + (await Option.None().MapAsync(x => Task.FromResult(Never(x)))).ShouldBe(Option.None()); + } + + /// A side effect that returns a task is waited for: it has run when the chain's await returns. + [Fact] + public async Task An_asynchronous_side_effect_is_waited_for() + { + var seen = 0; + + var option = await PendingSome.TapAsync(async x => { await Task.Delay(10); seen = x; }); + + seen.ShouldBe(2); + option.ShouldBe(Option.Some(2)); + (await Option.Some(4).TapAsync(async x => { await Task.Yield(); seen = x; })).ShouldBe(Option.Some(4)); + seen.ShouldBe(4); + } + + [Fact] + public async Task ToResultAsync_turns_absence_into_the_error() + { + (await PendingSome.ToResultAsync("missing")).ShouldBe(Result.Success(2)); + (await PendingNone.ToResultAsync("missing")).ShouldBe(Result.Error("missing")); + } + + /// The error is refused whether or not the option has a value, and before the task is awaited, as ToResult refuses it. + [Fact(Timeout = 10_000)] + public async Task ToResultAsync_refuses_a_null_error_before_the_option_is_known() + { + // Never completes: only a check made before the await can answer. The test's token ends it if not. + var never = new TaskCompletionSource>().Task.WaitAsync(TestContext.Current.CancellationToken); + + (await Should.ThrowAsync(() => never.ToResultAsync((string)null!))).ParamName.ShouldBe("error"); + await Should.ThrowAsync(() => PendingSome.ToResultAsync((string)null!)); + } + + [Fact] + public async Task A_selector_whose_task_produces_null_is_refused() + { + await Should.ThrowAsync(() => Option.Some(1).MapAsync(_ => Task.FromResult((string)null!))); + await Should.ThrowAsync(() => PendingSome.MapAsync(_ => (string)null!)); + } + + [Fact] + public async Task A_faulted_lookup_surfaces_its_own_exception() + { + var faulted = Task.FromException>(new TimeoutException()); + + await Should.ThrowAsync(() => faulted.MapAsync(x => x)); + await Should.ThrowAsync(() => faulted.ToResultAsync("missing")); + } +} From 09f33e559b7b2791ce19aefddd19d3c4b63563d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 14:36:00 +0200 Subject: [PATCH 024/147] feat: SingleOrNone, ElementAtOrNone and Transpose, the options CMTK0009 lacked CMTK0009 sent SingleOrDefault to the overload that takes a default value, which for a no-default struct is the hazard itself, and ElementAtOrDefault to Skip(index).FirstOrNone(). SingleOrNone answers the only element or None, and throws for more than one as SingleOrDefault does, since many is a defect and not absence; ElementAtOrNone answers the element at an int or an Index. On a query both ask the provider for what they need, two rows and one, and a negative index is None without a query, since Skip(-1) skips nothing. The EF Core satellite adds SingleOrNoneAsync, and CMTK0009 names each. Transpose turns an Option> into a Result, TError> and back, and Option.Traverse(selector) validates an optional input when present: dto.Phone.ToOption().Traverse(Phone.Create). Seeded: SingleOrNone answering the first of many, the query's ElementAtOrNone without its negative guard, and Transpose dropping an error each fail their tests, in the base and the EF Core suites. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + docs/plan.md | 4 +- .../DefaultProducingCallAnalyzer.cs | 15 ++- src/CodoMetis.TypeKit.Analyzers/README.md | 13 +- .../README.md | 4 +- .../TypeKitQueryableExtensions.cs | 46 ++++++- src/CodoMetis.TypeKit/OptionExtensions.cs | 109 ++++++++++++++++- src/CodoMetis.TypeKit/README.md | 7 +- src/CodoMetis.TypeKit/ResultExtensions.cs | 63 ++++++++++ .../DefaultProducingCallAnalyzerTests.cs | 35 +++--- .../QueryOptionTests.cs | 38 ++++++ .../NullDelegateTests.cs | 3 + .../OptionLookupTests.cs | 115 ++++++++++++++++++ .../UninitializedResultTests.cs | 3 + 14 files changed, 429 insertions(+), 32 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Tests/OptionLookupTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d74cb57..7ad81a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,12 @@ Semantic Versioning reserves for a major, listed under Changed as they land. `BindAsync`, `FilterAsync`, `TapAsync` and `ToResultAsync`, so a repository's lookup continues into a `Result` pipeline. Each checks its delegates, and `ToResultAsync` its error, before the option is known, as the `Task>` continuations do. +- **`CodoMetis.TypeKit`**: `SingleOrNone`, with or without a predicate, which throws for more than one + element as `SingleOrDefault` does, and `ElementAtOrNone`, by `int` or `Index`, on a sequence and on a + query (two rows and one, asked of the provider); `SingleOrNoneAsync` in the EF Core satellite; and + CMTK0009 names each for `SingleOrDefault`, `ElementAtOrDefault` and `SingleOrDefaultAsync`. `Transpose()` + between `Option>` and `Result, TError>`, and `Option.Traverse(selector)` + for an optional input validated when present. ### Changed diff --git a/docs/plan.md b/docs/plan.md index eb4eb0d..cf4ed42 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -606,7 +606,9 @@ Each phase ends green, and its guards have been proven by seeding the defect dotnet/runtime#83599); Serilog for closed sets ✅ (measured first: `{@Tier}` logged `ProbeTier { Rank: 2, Perk: "Priority support.", … }`); continuations of a `Task>` ✅ (the `Task` set's shapes: no `OrAsync`, no `MatchAsync`, no - `ValueTask` receiver); `Transpose`, `SingleOrNone` and `ElementAtOrNone`. + `ValueTask` receiver); `Transpose`, `SingleOrNone` and `ElementAtOrNone` ✅ (more than one is a + refusal, as `SingleOrDefault`'s; a negative index on a query is `None` without a query, since + `Skip(-1)` skips nothing). 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), last, since they read a smart enum's kind. 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict diff --git a/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs index 65fd89b..34e5e66 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DefaultProducingCallAnalyzer.cs @@ -159,8 +159,8 @@ private static Dictionary Forms(Compilation compilation, Ty { ("FirstOrDefault", "Use FirstOrNone, which returns an Option, or pass a default value", nothingFound, firstOrNoneInQuery), ("LastOrDefault", "Use LastOrNone, which returns an Option, or pass a default value", nothingFound, nullableFirst), - ("SingleOrDefault", defaultValue, nothingFound, nullableFirst), - ("ElementAtOrDefault", "Use Skip(index).FirstOrNone(), which returns an Option", pastTheEnd, "Use Skip(index).FirstOrNone(), which returns an Option and asks the query for one element"), + ("SingleOrDefault", "Use SingleOrNone, which returns an Option, or pass a default value", nothingFound, "Use SingleOrNone, which returns an Option and asks the query for two elements, or select a nullable first, as in Select(x => ({0}?)x)"), + ("ElementAtOrDefault", "Use ElementAtOrNone, which returns an Option", pastTheEnd, "Use ElementAtOrNone, which returns an Option and asks the query for one element"), ("DefaultIfEmpty", defaultValue, " for an empty sequence", nullableFirst), }) { @@ -178,10 +178,15 @@ private static Dictionary Forms(Compilation compilation, Ty var asyncEnumerables = compilation.GetTypesByMetadataName("System.Linq.AsyncEnumerable"); var entityFramework = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions"); - // FirstOrNoneAsync is the EF Core satellite's, so it is named only where the project can call it. - var firstOrNoneAsync = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.TypeKitQueryableExtensions") is not null + // FirstOrNoneAsync and SingleOrNoneAsync are the EF Core satellite's, so they are named only where + // the project can call them. + var satellite = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.TypeKitQueryableExtensions") is not null; + var firstOrNoneAsync = satellite ? "Use FirstOrNoneAsync, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => ({0}?)x)" : nullableFirst; + var singleOrNoneAsync = satellite + ? "Use SingleOrNoneAsync, which returns an Option and asks the query for two elements, or select a nullable first, as in Select(x => ({0}?)x)" + : nullableFirst; foreach (var (name, advice, circumstance) in new[] { @@ -197,7 +202,7 @@ private static Dictionary Forms(Compilation compilation, Ty AddWithoutDefaultValue(forms, asyncEnumerable, name, new Form(Kind.Sequence, Produced.TypeArgument, circumstance, advice)); } - AddWithoutDefaultValue(forms, entityFramework, name, new Form(Kind.Sequence, Produced.TypeArgument, circumstance, name == "FirstOrDefaultAsync" ? firstOrNoneAsync : nullableFirst)); + AddWithoutDefaultValue(forms, entityFramework, name, new Form(Kind.Sequence, Produced.TypeArgument, circumstance, name switch { "FirstOrDefaultAsync" => firstOrNoneAsync, "SingleOrDefaultAsync" => singleOrNoneAsync, _ => nullableFirst })); } // List.Find and its kind return default(T) when nothing matches, as FirstOrDefault does. diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 72a3d0d..7a670d6 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -294,12 +294,13 @@ var first = ids.FirstOrDefault(); // CMTK0009: an OrderId nob | Call | Instead | |---|---| | `Enumerable.FirstOrDefault`, `LastOrDefault`, and `ImmutableArray`'s own | `FirstOrNone`, `LastOrNone`, which return an `Option`, or the overload that takes a default value | -| `Enumerable.SingleOrDefault`, `DefaultIfEmpty`, and `ImmutableArray`'s own | the overload that takes a default value | -| `Enumerable.ElementAtOrDefault`, and `ImmutableArray`'s own | `Skip(index).FirstOrNone()` | -| `Queryable.FirstOrDefault`, `ElementAtOrDefault` | `FirstOrNone`, `Skip(index).FirstOrNone()`, which ask the query for one element, or the nullable projection below | -| The rest on `Queryable` | select a nullable first, `Select(x => (OrderId?)x).LastOrDefault()`, which a query provider translates | -| EF Core's `FirstOrDefaultAsync` | `FirstOrNoneAsync` of `CodoMetis.TypeKit.EntityFrameworkCore`, named where the project references it, or the nullable projection | -| EF Core's `LastOrDefaultAsync`, `SingleOrDefaultAsync`, `ElementAtOrDefaultAsync` | the same nullable projection, `Select(x => (OrderId?)x).SingleOrDefaultAsync()`; EF Core has no overload that takes a default value | +| `Enumerable.SingleOrDefault`, and `ImmutableArray`'s own | `SingleOrNone`, which returns an `Option` and still throws for more than one, or the overload that takes a default value | +| `Enumerable.DefaultIfEmpty`, and `ImmutableArray`'s own | the overload that takes a default value | +| `Enumerable.ElementAtOrDefault`, and `ImmutableArray`'s own | `ElementAtOrNone(index)` | +| `Queryable.FirstOrDefault`, `SingleOrDefault`, `ElementAtOrDefault` | `FirstOrNone`, `SingleOrNone`, `ElementAtOrNone`, which ask the query for one element (two for `SingleOrNone`), or the nullable projection below | +| `Queryable.LastOrDefault`, `DefaultIfEmpty` | select a nullable first, `Select(x => (OrderId?)x).LastOrDefault()`, which a query provider translates | +| EF Core's `FirstOrDefaultAsync`, `SingleOrDefaultAsync` | `FirstOrNoneAsync`, `SingleOrNoneAsync` of `CodoMetis.TypeKit.EntityFrameworkCore`, named where the project references it, or the nullable projection | +| EF Core's `LastOrDefaultAsync`, `ElementAtOrDefaultAsync` | the same nullable projection, `Select(x => (OrderId?)x).LastOrDefaultAsync()`; EF Core has no overload that takes a default value | | `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync`, `DefaultIfEmpty` on an `IAsyncEnumerable` | the overload that takes a default value | | `ElementAtOrDefaultAsync` on an `IAsyncEnumerable` | the nullable projection | | `Find`, `FindLast` of a `List`, an array (`Array.Find`) or an `ImmutableList` | `FirstOrNone(predicate)`, `LastOrNone(predicate)` | diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 0602e1c..b02e7ba 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -138,7 +138,9 @@ Result found = db.Orders.FirstOrError(o => o.Id == id, OrderF `FirstOrNoneAsync`, and the base package's `FirstOrNone` and `FirstOrError` on a query, ask the database for one row and let it run the condition, `WHERE … LIMIT @p`, where `FirstOrDefaultAsync` -hands back a `default` value object for nothing found (CMTK0009). On a query, `LastOrNone` and +hands back a `default` value object for nothing found (CMTK0009). `SingleOrNoneAsync` and +`SingleOrNone` ask for two rows and throw for more than one, and `ElementAtOrNone` skips to its row, +`OFFSET @p`. On a query, `LastOrNone` and `LastOrError` are compile errors: a query has no last row until it is ordered, so order it the other way and take the first, or call `AsEnumerable()` to read every row on purpose. diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs index 0a70bdb..9636046 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitQueryableExtensions.cs @@ -5,8 +5,9 @@ namespace Microsoft.EntityFrameworkCore; /// -/// FirstOrNoneAsync: the first element of a query as an , asked of the -/// database, where FirstOrDefaultAsync hands back a default for nothing found. +/// FirstOrNoneAsync and SingleOrNoneAsync: an element of a query as an +/// , asked of the database, where FirstOrDefaultAsync and +/// SingleOrDefaultAsync hand back a default for nothing found. /// /// /// The condition and the limit run in the query, WHERE … LIMIT @p, as they do for the base @@ -44,6 +45,32 @@ public Task> FirstOrNoneAsync(Expression> predicate, Can return FirstOfAsync(source.Where(predicate).Take(1), cancellationToken); } + + /// The only element of the query, or None: the database is asked for two, and more than one throws. + /// Cancels the query. + /// The only element, or None if the query has none. + /// The query has more than one element. + /// The only element is null. + public Task> SingleOrNoneAsync(CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + + return SingleOfAsync(source.Take(2), cancellationToken); + } + + /// The only element that matches, or None: the database runs the condition and is asked for two. + /// The condition to match, translated to SQL. + /// Cancels the query. + /// The only matching element, or None if nothing matches. + /// More than one element matches. + /// The only matching element is null. + public Task> SingleOrNoneAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + + return SingleOfAsync(source.Where(predicate).Take(2), cancellationToken); + } } private static async Task> FirstOfAsync(IQueryable limited, CancellationToken cancellationToken) where T : notnull @@ -53,4 +80,19 @@ private static async Task> FirstOfAsync(IQueryable limited, Canc return Option.None(); } + + private static async Task> SingleOfAsync(IQueryable limited, CancellationToken cancellationToken) where T : notnull + { + var found = Option.None(); + + await foreach (var element in limited.AsAsyncEnumerable().WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (found.IsSome()) + throw new InvalidOperationException("The query holds more than one element, so SingleOrNoneAsync has no single one to answer. Use FirstOrNoneAsync where any one will do."); + + found = Option.Some(element); + } + + return found; + } } diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index a3bee2c..91a714b 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -428,6 +428,64 @@ public Option LastOrNone(Func predicate) => .Select(Some) .DefaultIfEmpty(None()) .LastOrDefault(); + + /// + /// The only element, or None for an empty sequence. More than one is not absence: it + /// throws, as SingleOrDefault does, since reading it as None would hide the defect. + /// + /// The only element, or None if there is none. + /// The sequence holds more than one element. + /// The only element is null. + public Option SingleOrNone() + { + ArgumentNullException.ThrowIfNull(source); + + return SingleOf(source); + } + + /// The only element that matches, or None; more than one throws, as SingleOrDefault does. + /// The condition to match. + /// The only matching element, or None if nothing matches. + /// More than one element matches. + /// The only matching element is null. + public Option SingleOrNone(Func predicate) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + + return SingleOf(source.Where(predicate)); + } + + /// The element at , or None past either end. + /// The zero-based index; a negative one is None. + /// The element, or None if there is none at that index. + /// The element is null. + public Option ElementAtOrNone(int index) + { + ArgumentNullException.ThrowIfNull(source); + + if (index < 0) return None(); + if (source is IReadOnlyList list) return index < list.Count ? Some(list[index]) : None(); + + return FirstOf(source.Skip(index)); + } + + /// The element at , from the start or from the end (^1 is the last), or None past either end. + /// The index. + /// The element, or None if there is none at that index. + /// The element is null. + public Option ElementAtOrNone(Index index) + { + ArgumentNullException.ThrowIfNull(source); + + if (!index.IsFromEnd) return source.ElementAtOrNone(index.Value); + if (index.Value == 0) return None(); + if (source is IReadOnlyList list) return index.Value <= list.Count ? Some(list[^index.Value]) : None(); + + // The last n elements, of which the first is the one asked for, if there are n. + var last = source.TakeLast(index.Value).ToList(); + return last.Count == index.Value ? Some(last[0]) : None(); + } } /// The query. @@ -462,6 +520,41 @@ public Option FirstOrNone(System.Linq.Expressions.Expression> p return FirstOf(source.Where(predicate).Take(1)); } + /// The only element of the query, or None: the provider is asked for two, and more than one throws. + /// The only element, or None if the query has none. + /// The query has more than one element. + /// The only element is null. + public Option SingleOrNone() + { + ArgumentNullException.ThrowIfNull(source); + + return SingleOf(source.Take(2)); + } + + /// The only element that matches, or None: the provider runs the condition and is asked for two. + /// The condition to match, translated by the query provider. + /// The only matching element, or None if nothing matches. + /// More than one element matches. + /// The only matching element is null. + public Option SingleOrNone(System.Linq.Expressions.Expression> predicate) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + + return SingleOf(source.Where(predicate).Take(2)); + } + + /// The element at , or None: the provider skips to it and is asked for one. + /// The zero-based index; a negative one is None, without a query, since a query skips nothing for it. + /// The element, or None if there is none at that index. + /// The element is null. + public Option ElementAtOrNone(int index) + { + ArgumentNullException.ThrowIfNull(source); + + return index < 0 ? None() : FirstOf(source.Skip(index).Take(1)); + } + /// Refused: a query has no last element until it is ordered, and this would read every row to find one. /// Never: the overload does not compile. [Obsolete("A query has no last element until it is ordered, and LastOrNone would read every row to find one. Order it the other way and use FirstOrNone, or call AsEnumerable() to read it all on purpose.", error: true)] @@ -476,14 +569,26 @@ public Option FirstOrNone(System.Linq.Expressions.Expression> p public Option LastOrNone(System.Linq.Expressions.Expression> predicate) => throw new NotSupportedException(); } - /// The first element a query limited to one answers, or None. - private static Option FirstOf(IQueryable limited) where T : notnull + /// The first element, or None; on a query, one limited to one, so it reads one row. + private static Option FirstOf(IEnumerable limited) where T : notnull { using var enumerator = limited.GetEnumerator(); return enumerator.MoveNext() ? Some(enumerator.Current) : None(); } + /// The only element, None for none, and a refusal for more; on a query, one limited to two. + private static Option SingleOf(IEnumerable elements) where T : notnull + { + using var enumerator = elements.GetEnumerator(); + if (!enumerator.MoveNext()) return None(); + + var single = enumerator.Current; + if (enumerator.MoveNext()) throw new InvalidOperationException("The sequence holds more than one element, so SingleOrNone has no single one to answer. Use FirstOrNone where any one will do."); + + return Some(single); + } + /// The dictionary. /// The type of the keys. /// The type of the values. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index c228600..8b36b83 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -48,7 +48,12 @@ Create one with `Option.Some(value)` and `Option.None()`, or lift a nullable wit `GetValueOrNone(key)` looks a key up in a dictionary, and `FirstOrNone()` and `LastOrNone()` take the end of a sequence, with or without a predicate. On a query, `FirstOrNone` and `FirstOrError` ask the provider for one element and let it run the predicate (a `WHERE` and a `LIMIT` in SQL), and `LastOrNone` is a -compile error, since a query has no last element until it is ordered. +compile error, since a query has no last element until it is ordered. `SingleOrNone()` answers the +only element, `None` for none, and throws for more than one, as `SingleOrDefault` does, since many +is a defect and not absence; `ElementAtOrNone(index)` answers the element at an index, `^1` included. +An optional input that is validated when present is `Traverse`: `dto.Phone.ToOption().Traverse(Phone.Create)` +is a `Result, PhoneFault>`, and `Transpose()` turns an `Option>` inside out, +and a `Result>` back. `Option.None()` takes its type from where it goes (`return Option.None();`, a conditional beside `Some`); where nothing supplies one, as with `var`, write `Option.None()`. `Some(null)` throws, so an option that reports a value always has one. A `default(Option)` is diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 3074419..2859c8a 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -983,6 +983,69 @@ public Result Sequence() } } + /// An optional input that is validated when present. + /// The type of the value. + /// The type of the error. + extension(Option> option) where T : notnull where TError : notnull + { + /// + /// Turns an optional result inside out: None is a success with no value, a success is a + /// success with the value, and an error stays the error. + /// + /// A success holding an option, or the error. + /// The option holds an uninitialized result. + public Result, TError> Transpose() + { + if (!option.TryGetValue(out var result)) return Result, TError>.Success(Option.None()); + + return result.TryGetValue(out var value, out var error) + ? Result, TError>.Success(Option.Some(value)) + : Result, TError>.Error(error); + } + } + + /// A result whose value may be absent. + /// The type of the value. + /// The type of the error. + extension(Result, TError> result) where T : notnull where TError : notnull + { + /// + /// Turns a result of an option inside out: a success with no value is None, a success + /// with a value is that success, and an error is an option holding the error. + /// + /// An option holding a result, or None. + /// The result is uninitialized. + public Option> Transpose() + { + if (!result.TryGetValue(out var option, out var error)) return Option.Some(Result.Error(error)); + + return option.TryGetValue(out var value) ? Option.Some(Result.Success(value)) : Option.None>(); + } + } + + /// An optional input. + /// The type of the value. + extension(Option option) where T : notnull + { + /// + /// Applies an operation that may fail to the value, if there is one: an optional input is + /// validated when present, dto.Phone.ToOption().Traverse(Phone.Create). + /// + /// Called with the value if there is one. + /// The type of the produced value. + /// The type of the error. + /// A success with no value for None, a success with the produced value, or its error. + /// returned an uninitialized result. + public Result, TError> Traverse(Func> selector) + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(selector); + + return option.Map(selector).Transpose(); + } + } + /// /// A zip inspects every argument before the first error wins: an uninitialized result is a bug /// wherever it stands, and short-circuiting would let one after an error pass unnoticed. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs index eab8725..8189a2b 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultProducingCallAnalyzerTests.cs @@ -65,6 +65,12 @@ private static AnalyzerTest TestWithEntityFramewor private const string NullableFirst = "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"; + private const string SingleOrNone = "Use SingleOrNone, which returns an Option, or pass a default value"; + + private const string ElementAtOrNone = "Use ElementAtOrNone, which returns an Option"; + + private const string SingleOrNoneAsync = "Use SingleOrNoneAsync, which returns an Option and asks the query for two elements, or select a nullable first, as in Select(x => (Plain?)x)"; + private const string FirstOrNoneAsync = "Use FirstOrNoneAsync, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => (Plain?)x)"; private const string MissingKey = " for a missing key"; @@ -119,17 +125,18 @@ public static void Run(List ids) Cmtk0009(1, "FirstOrDefault", "Plain", NothingFound, FirstOrNone), Cmtk0009(2, "LastOrDefault", "Plain", NothingFound, LastOrNone), Cmtk0009(3, "LastOrDefault", "Plain", NothingFound, LastOrNone), - Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(6, "ElementAtOrDefault", "Plain", " for an index past the end", "Use Skip(index).FirstOrNone(), which returns an Option"), - Cmtk0009(7, "ElementAtOrDefault", "Plain", " for an index past the end", "Use Skip(index).FirstOrNone(), which returns an Option"), + Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(6, "ElementAtOrDefault", "Plain", " for an index past the end", ElementAtOrNone), + Cmtk0009(7, "ElementAtOrDefault", "Plain", " for an index past the end", ElementAtOrNone), Cmtk0009(8, "DefaultIfEmpty", "Plain", " for an empty sequence", DefaultValue), Cmtk0009(9, "FirstOrDefault", "Plain", NothingFound, FirstOrNone)) .RunAsync(TestContext.Current.CancellationToken); /// - /// In a query, FirstOrNone asks the provider for one element, as Skip(index).FirstOrNone() - /// does; the rest have no query form beside the nullable projection, which every provider translates. + /// In a query, FirstOrNone and ElementAtOrNone ask the provider for one element and + /// SingleOrNone for two; the rest have no query form beside the nullable projection, which + /// every provider translates. /// [Fact] public Task Every_query_form_without_a_default_value_reports() => @@ -149,8 +156,8 @@ public static void Run(IQueryable ids) """, Cmtk0009(0, "FirstOrDefault", "Plain", NothingFound, "Use FirstOrNone, which returns an Option and asks the query for one element, or select a nullable first, as in Select(x => (Plain?)x)"), Cmtk0009(1, "LastOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), - Cmtk0009(2, "SingleOrDefault", "Plain", NothingFound, "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null"), - Cmtk0009(3, "ElementAtOrDefault", "Plain", " for an index past the end", "Use Skip(index).FirstOrNone(), which returns an Option and asks the query for one element"), + Cmtk0009(2, "SingleOrDefault", "Plain", NothingFound, "Use SingleOrNone, which returns an Option and asks the query for two elements, or select a nullable first, as in Select(x => (Plain?)x)"), + Cmtk0009(3, "ElementAtOrDefault", "Plain", " for an index past the end", "Use ElementAtOrNone, which returns an Option and asks the query for one element"), Cmtk0009(4, "DefaultIfEmpty", "Plain", " for an empty sequence", "Select a nullable first, as in Select(x => (Plain?)x), so that nothing found is null")) .RunAsync(TestContext.Current.CancellationToken); @@ -255,9 +262,9 @@ public static void Run(ImmutableArray ids, ImmutableArray.Builder Cmtk0009(1, "FirstOrDefault", "Plain", NothingFound, FirstOrNone), Cmtk0009(2, "LastOrDefault", "Plain", NothingFound, LastOrNone), Cmtk0009(3, "LastOrDefault", "Plain", NothingFound, LastOrNone), - Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, DefaultValue), - Cmtk0009(6, "ElementAtOrDefault", "Plain", PastTheEnd, "Use Skip(index).FirstOrNone(), which returns an Option"), + Cmtk0009(4, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(5, "SingleOrDefault", "Plain", NothingFound, SingleOrNone), + Cmtk0009(6, "ElementAtOrDefault", "Plain", PastTheEnd, ElementAtOrNone), Cmtk0009(7, "FirstOrDefault", "Plain", NothingFound, FirstOrNone), Cmtk0009(8, "LastOrDefault", "Plain", NothingFound, LastOrNone), Cmtk0009(9, "GetValueOrDefault", "Plain", MissingKey, GetValueOrNone), @@ -337,8 +344,8 @@ public static async Task Run(IQueryable ids, System.Threading.Cancellatio Cmtk0009(1, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync), Cmtk0009(2, "LastOrDefaultAsync", "Plain", NothingFound, NullableFirst), Cmtk0009(3, "LastOrDefaultAsync", "Plain", NothingFound, NullableFirst), - Cmtk0009(4, "SingleOrDefaultAsync", "Plain", NothingFound, NullableFirst), - Cmtk0009(5, "SingleOrDefaultAsync", "Plain", NothingFound, NullableFirst), + Cmtk0009(4, "SingleOrDefaultAsync", "Plain", NothingFound, SingleOrNoneAsync), + Cmtk0009(5, "SingleOrDefaultAsync", "Plain", NothingFound, SingleOrNoneAsync), Cmtk0009(6, "ElementAtOrDefaultAsync", "Plain", PastTheEnd, NullableFirst), Cmtk0009(7, "FirstOrDefaultAsync", "Plain", NothingFound, FirstOrNoneAsync)) .RunAsync(TestContext.Current.CancellationToken); @@ -416,7 +423,7 @@ public static void Run(List> options, List> resul """, Cmtk0009(0, "FirstOrDefault", "Option", NothingFound, FirstOrNone), Cmtk0009(1, "LastOrDefault", "Result", NothingFound, LastOrNone), - Cmtk0009(2, "SingleOrDefault", "Guarded", NothingFound, DefaultValue), + Cmtk0009(2, "SingleOrDefault", "Guarded", NothingFound, SingleOrNone), Cmtk0009(3, "FirstOrDefault", "Validated", NothingFound, FirstOrNone), Cmtk0009(4, "FirstOrDefault", "T", NothingFound, FirstOrNone), Cmtk0009(5, "FirstOrDefault", "T", NothingFound, FirstOrNone)) diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs index aad8d31..c0baa29 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs @@ -73,6 +73,44 @@ public async Task A_condition_only_the_database_can_run_is_translated() result.Carrier.ShouldBe("alpha"); } + /// The database runs the condition and is asked for two rows: enough to tell one from many. + [Fact] + public async Task SingleOrNone_asks_for_two_rows_and_refuses_more_than_one() + { + await using var db = Db(); + + db.Shipments.SingleOrNone(s => s.Carrier == "beta").TryGetValue(out var found).ShouldBeTrue(); + found.Carrier.ShouldBe("beta"); + var sql = _commands.ShouldHaveSingleItem(); + sql.ShouldContain("WHERE \"s\".\"Carrier\" = 'beta'"); + sql.ShouldContain("LIMIT @p"); + + Should.Throw(() => db.Shipments.SingleOrNone()); + db.Shipments.SingleOrNone(s => s.Carrier == "delta").TryGetValue(out _).ShouldBeFalse(); + + (await db.Shipments.SingleOrNoneAsync(s => EF.Functions.Like(s.Carrier, "g%"), TestContext.Current.CancellationToken)).TryGetValue(out var async).ShouldBeTrue(); + async.Carrier.ShouldBe("gamma"); + await Should.ThrowAsync(() => db.Shipments.SingleOrNoneAsync(TestContext.Current.CancellationToken)); + (await db.Shipments.Where(s => s.Carrier == "delta").SingleOrNoneAsync(TestContext.Current.CancellationToken)).TryGetValue(out _).ShouldBeFalse(); + } + + [Fact] + public void ElementAtOrNone_skips_to_the_row_in_the_query() + { + using var db = Db(); + + db.Shipments.OrderBy(s => s.Carrier).ElementAtOrNone(1).TryGetValue(out var second).ShouldBeTrue(); + second.Carrier.ShouldBe("beta"); + var sql = _commands.ShouldHaveSingleItem(); + sql.ShouldContain("LIMIT @p"); + sql.ShouldContain("OFFSET @p"); + + db.Shipments.ElementAtOrNone(3).TryGetValue(out _).ShouldBeFalse(); + _commands.Clear(); + db.Shipments.ElementAtOrNone(-1).TryGetValue(out _).ShouldBeFalse(); + _commands.ShouldBeEmpty("a negative index asks nothing of the database"); + } + [Fact] public async Task FirstOrNoneAsync_reads_one_row_and_answers_None_for_nothing_found() { diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index 9162a9a..e73637b 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -86,6 +86,9 @@ public sealed class NullDelegateTests ["Option.Zip(Option, Option, Option, Option, Option, Option, Func): selector"] = [() => Some.Zip(Some, Some, Some, Some, Some, Null>()), () => None.Zip(Some, Some, Some, Some, Some, Null>())], ["Option.FirstOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.FirstOrNone(Null>()), () => Array.Empty().FirstOrNone(Null>())], + ["Option.SingleOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.SingleOrNone(Null>()), () => Array.Empty().SingleOrNone(Null>())], + ["Result.Traverse(Option, Func>): selector"] = + [() => Some.Traverse(Null>>()), () => None.Traverse(Null>>())], ["Option.LastOrNone(IEnumerable, Func): predicate"] = [() => new[] { 1 }.LastOrNone(Null>()), () => Array.Empty().LastOrNone(Null>())], ["Patch.Match(Func, Func, Func): onValue"] = diff --git a/test/CodoMetis.TypeKit.Tests/OptionLookupTests.cs b/test/CodoMetis.TypeKit.Tests/OptionLookupTests.cs new file mode 100644 index 0000000..57c55a8 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/OptionLookupTests.cs @@ -0,0 +1,115 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// SingleOrNone and ElementAtOrNone, the options CMTK0009 has to offer for +/// SingleOrDefault and ElementAtOrDefault, and Transpose/Traverse between +/// an option and a result, for an optional input that is validated when present. +/// +public sealed class OptionLookupTests +{ + private enum Fault { Refused } + + [Fact] + public void SingleOrNone_answers_the_only_element_or_None_and_refuses_more_than_one() + { + new[] { 7 }.SingleOrNone().ShouldBe(Option.Some(7)); + Array.Empty().SingleOrNone().ShouldBe(Option.None()); + new[] { 1, 2, 3 }.SingleOrNone(x => x == 2).ShouldBe(Option.Some(2)); + new[] { 1, 2, 3 }.SingleOrNone(x => x > 5).ShouldBe(Option.None()); + + // More than one is a defect, not absence: as SingleOrDefault, it throws. + Should.Throw(() => new[] { 1, 2 }.SingleOrNone()).Message.ShouldContain("FirstOrNone"); + Should.Throw(() => new[] { 1, 2, 3 }.SingleOrNone(x => x > 1)); + } + + [Fact] + public void ElementAtOrNone_answers_the_element_or_None_past_either_end() + { + IReadOnlyList list = [10, 20, 30]; + IEnumerable sequence = Enumerable.Range(1, 3).Select(x => x * 10); + + list.ElementAtOrNone(1).ShouldBe(Option.Some(20)); + sequence.ElementAtOrNone(2).ShouldBe(Option.Some(30)); + list.ElementAtOrNone(3).ShouldBe(Option.None()); + sequence.ElementAtOrNone(3).ShouldBe(Option.None()); + list.ElementAtOrNone(-1).ShouldBe(Option.None()); + sequence.ElementAtOrNone(-1).ShouldBe(Option.None()); + list.ElementAtOrNone(^1).ShouldBe(Option.Some(30)); + sequence.ElementAtOrNone(^3).ShouldBe(Option.Some(10)); + sequence.ElementAtOrNone(^1).ShouldBe(Option.Some(30)); + list.ElementAtOrNone(^4).ShouldBe(Option.None()); + sequence.ElementAtOrNone(^4).ShouldBe(Option.None()); + sequence.ElementAtOrNone(^0).ShouldBe(Option.None()); + sequence.ElementAtOrNone(new Index(1)).ShouldBe(Option.Some(20)); + } + + /// On a query, a negative index is None without a query: Skip(-1) skips nothing and would answer the first element. + [Fact] + public void On_a_query_they_answer_as_on_a_sequence() + { + var query = new[] { 10, 20, 30 }.AsQueryable(); + + query.SingleOrNone(x => x == 20).ShouldBe(Option.Some(20)); + query.SingleOrNone(x => x > 99).ShouldBe(Option.None()); + Should.Throw(() => query.SingleOrNone()); + query.ElementAtOrNone(2).ShouldBe(Option.Some(30)); + query.ElementAtOrNone(3).ShouldBe(Option.None()); + query.ElementAtOrNone(-1).ShouldBe(Option.None()); + } + + [Fact] + public void Transpose_turns_an_optional_result_inside_out() + { + Option.None>().Transpose().ShouldBe(Result, Fault>.Success(Option.None())); + Option.Some(Result.Success(3)).Transpose().ShouldBe(Result, Fault>.Success(Option.Some(3))); + Option.Some(Result.Error(Fault.Refused)).Transpose().ShouldBe(Result, Fault>.Error(Fault.Refused)); + } + + [Fact] + public void Transpose_turns_a_result_of_an_option_inside_out() + { + Result, Fault>.Success(Option.None()).Transpose().ShouldBe(Option.None>()); + Result, Fault>.Success(Option.Some(3)).Transpose().ShouldBe(Option.Some(Result.Success(3))); + Result, Fault>.Error(Fault.Refused).Transpose().ShouldBe(Option.Some(Result.Error(Fault.Refused))); + } + + [Fact] + public void Traverse_validates_an_optional_input_only_when_present() + { + static Result Create(string text) => text.Length > 0 ? Result.Success(text) : Result.Error(Fault.Refused); + + Option.Some("a").Traverse(Create).ShouldBe(Result, Fault>.Success(Option.Some("a"))); + Option.Some("").Traverse(Create).ShouldBe(Result, Fault>.Error(Fault.Refused)); + Option.None().Traverse(_ => throw new InvalidOperationException("called on None")) + .ShouldBe(Result, Fault>.Success(Option.None())); + } + + /// An uninitialized result inside an option is a bug at the site that made it, refused as everywhere else. + [Fact] + public void An_uninitialized_result_inside_is_refused() + { + Should.Throw(() => Option.Some(default(Result)).Transpose()); + Should.Throw(() => Option.Some(1).Traverse(_ => default(Result))); + Should.Throw(() => default(Result, Fault>).Transpose()); + } + + /// A method group infers both type arguments, the shape the README shows. + [Fact] + public void Traverse_infers_from_a_method_group() => + ConsumerCompilation.Of( + """ + using CodoMetis.TypeKit; + + enum PhoneFault { Malformed } + + readonly record struct Phone(string Text) + { + public static Result Create(string text) => new Phone(text); + } + + static class Consumer + { + static Result, PhoneFault> Read(string? phone) => phone.ToOption().Traverse(Phone.Create); + } + """).Problems().ShouldBeEmpty(); +} diff --git a/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs b/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs index e0ffae3..2f08dbd 100644 --- a/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs @@ -81,6 +81,9 @@ public sealed class UninitializedResultTests { ["Select(Result, Func)"] = () => Task.FromResult(default(Result).Select(x => x)), ["ToOption(Result)"] = () => Task.FromResult(default(Result).ToOption()), + ["Transpose(Option>)"] = () => Task.FromResult(Option.Some(default(Result)).Transpose()), + ["Transpose(Result, TError>)"] = () => Task.FromResult(default(Result, string>).Transpose()), + ["Traverse(Option, Func>)"] = () => Task.FromResult(Option.Some(1).Traverse(_ => default(Result))), ["SelectMany(Result, Func>, Func)"] = () => Task.FromResult(default(Result).SelectMany(_ => Value, (x, _) => x)), From 61b23b4d346814c7a699aae6dff1e5348378070d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 15:21:06 +0200 Subject: [PATCH 025/147] docs: the merge-patch and closed-set pages, compiled like the rest Two topic pages for the 2.0.0 features that landed before the docs did, each linked from the root README's Documentation table, its blocks compiled from test/CodoMetis.TypeKit.Samples/Docs and its stated outputs tested. docs/merge-patches.md: the three states and what each member does with them, applying a patch to an entity (a required field refusing a clear, a struct's clear arriving as null), a value object inside a patch keeping its rule, writing a body with WhenWritingDefault, the endpoint and its document, source-generated contexts, and what a patch is not (a deep merge, an Option). docs/closed-sets.md: declaring a set with data on its members, Name, All, Parse and their refusal, Match with named arguments, JSON values and dictionary keys, the OpenAPI enum, a route parameter (and ASP0020 in the declaring project), the EF Core column and its SQL, configuration binding and the source generator that cannot, logging, every build error, and when an enum is still right. The tests beside them also check what the prose claims: the document's patch schemas, the string enum, the route binding and its 400, the text[] column on PostgreSQL (the EF README said a JSON array; corrected there too). Seeded: a wrong stated output in either page fails its test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- README.md | 10 +- design/plan.md | 4 +- docs/closed-sets.md | 171 +++++++++++++ docs/merge-patches.md | 171 +++++++++++++ .../README.md | 4 +- .../Docs/ClosedSetsSamples.cs | 208 +++++++++++++++ .../Docs/MergePatchesSamples.cs | 240 ++++++++++++++++++ 8 files changed, 800 insertions(+), 12 deletions(-) create mode 100644 docs/closed-sets.md create mode 100644 docs/merge-patches.md create mode 100644 test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1f6b9..1429290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,8 +111,8 @@ Semantic Versioning reserves for a major, listed under Changed as they land. between `Option>` and `Result, TError>`, and `Option.Traverse(selector)` for an optional input validated when present. - **Documentation**: topic pages under `docs/`, linked from the root README's "Documentation" table: - value objects, `Option`, `Result`, written text, type constraints, JSON, EF Core, ASP.NET Core, - Native AOT, logging and secrets, and testing. Every C# block in them is compiled and its stated + value objects, closed sets, `Option`, `Result`, written text, type constraints, merge patches, + JSON, EF Core, ASP.NET Core, Native AOT, logging and secrets, and testing. Every C# block in them is compiled and its stated outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete public surface, checked against the types. The package READMEs remain the reference for each package. The plan moved from `docs/plan.md` to `design/plan.md`. diff --git a/README.md b/README.md index da56fc1..88beebe 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,12 @@ compiled and its stated outputs are tested, as the READMEs' are. | Page | Covers | |---|---| | [Value objects](docs/value-objects.md) | Declaring one, struct or class, identifiers, validated text and numbers, faults, which factory to call, your own members, generic code over value objects | +| [Closed sets](docs/closed-sets.md) | Smart enums: declaring one, data on its members, `Match`, JSON and dictionary keys, EF Core columns, configuration, logging, what the generators refuse, when an `enum` is still right | | [Option](docs/option.md) | Absence as a type: creating, matching, combining, converting at the boundary, and the complete public surface | | [Result](docs/result.md) | Outcomes as a type: faults, pipelines, async chains, collecting results, an exception as an outcome, errors across layers, and the complete public surface | | [Written text](docs/text.md) | `AsText()` and `AsIdentifier()`: what each cleans and refuses, a value object over either, a required and an optional field | | [Type constraints](docs/type-constraints.md) | `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` on a type parameter, `[RequireParameterlessConstructor]` and its code fix | +| [Merge patches](docs/merge-patches.md) | `Patch`: absent, null or a value, applying a patch, value objects inside one, writing one, the OpenAPI document, source-generated contexts | | [JSON](docs/json.md) | What a value object writes and reads, refusals, stored documents, `Option` and `Result` at the wire, a converter of your own, source-generated contexts | | [EF Core](docs/ef-core.md) | Columns, keys, conventions, queries and the SQL they become, seed data, reading back without validation, SQLite in tests | | [ASP.NET Core](docs/aspnet-core.md) | Binding in minimal APIs and MVC, what a refused value answers, a `Result` as a response, the OpenAPI document, problem details | @@ -113,12 +115,8 @@ compiled and its stated outputs are tested, as the READMEs' are. | [Logging and secrets](docs/logging-and-secrets.md) | A value that must not be printed, what still carries it, Serilog, Microsoft.Extensions.Logging, refusals that never quote the input | | [Testing](docs/testing.md) | Assertions for `Option` and `Result`, table-driven tests of `Create`, EF Core on SQLite, endpoints on the test server | -`Patch` and the closed-set (smart enum) family have landed in 2.0.0 and are documented in the -[base package's README](src/CodoMetis.TypeKit/README.md#merge-patches) and -[the generators' README](src/CodoMetis.TypeKit.Generators/README.md#closed-sets) until their pages -are written. Still to come in 2.0.0, each page written when its feature lands -([the plan](design/plan.md#2-phases), phase 13): fault responses a client can act on, and public -aspects. +Still to come in 2.0.0, each page written when its feature lands ([the plan](design/plan.md#2-phases), +phase 13): fault responses a client can act on, and public aspects. ## What it holds to diff --git a/design/plan.md b/design/plan.md index 09a0571..5623853 100644 --- a/design/plan.md +++ b/design/plan.md @@ -1478,8 +1478,8 @@ Still open: `result.md`) is checked against the types by reflection, so a member added without a row fails. The plan and the design notes moved to `design/`, so that `docs/` holds only what a consumer reads and nothing there is exempt from compilation; `design/` is a solution folder. Eleven pages - ship with 2.0.0; `Patch`, the closed sets, the fault responses and public aspects get theirs - as they land. + came first; `merge-patches.md` and `closed-sets.md` joined them once the docs branch merged into + 2.0.0 (2026-10-13), and the fault responses and public aspects get theirs as they land. 46. **A timeout caught by `Result.Catch` is the error** (2026-10-13, phase 13). Every overload let an `OperationCanceledException` through, and `HttpClient` throws one over a `TimeoutException` diff --git a/docs/closed-sets.md b/docs/closed-sets.md new file mode 100644 index 0000000..30b448a --- /dev/null +++ b/docs/closed-sets.md @@ -0,0 +1,171 @@ +# Closed sets + +An `enum` names its members and nothing more: C# never proves a `switch` over one complete, a member +carries no data, and on the wire it is a number unless a converter says otherwise. A closed set, a +smart enum, is a sealed class whose every instance is a named member declared as a field of it, with +whatever data and behaviour the members need; the generators add the rest. The +[generators' README](../src/CodoMetis.TypeKit.Generators/README.md#closed-sets) is its reference; +this page is about using one: declaring it, deciding by member, on the wire, in the database, from +configuration and in logs. The examples import `CodoMetis.TypeKit.ClosedSets`. + +## Declaring one + +```csharp +public sealed partial class Shipping : IClosedSet +{ + public static readonly Shipping Standard = new(days: 5, fee: 0m); + public static readonly Shipping Express = new(days: 1, fee: 9.90m); + public static readonly Shipping Pickup = new(days: 0, fee: 0m); + + private Shipping(int days, decimal fee) { Days = days; Fee = fee; } + + public int Days { get; } + public decimal Fee { get; } + + public DateOnly ArrivesBy(DateOnly ordered) => ordered.AddDays(Days); +} +``` + +A member is a `public static readonly` field of the set's own type, created with `new` where it is +declared, and its name is the field's. The set is a `sealed partial class` whose constructors are +private, so no code outside it can make an instance: members are singletons, and equality is +reference equality. What belongs to a member lives on it, as `Fee` and `ArrivesBy` do; a set without +data declares no constructor, and the generators add a private one. + +```csharp +string name = Shipping.Express.Name; // "Express" +string qualified = Shipping.Express.QualifiedName; // "Shipping.Express" +string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" +bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons +bool known = Shipping.TryParse("Drone", out _); // false, names compare ordinally +Shipping.Parse("Drone"); // FormatException naming Shipping and member, never the name +``` + +`All` holds the members in declaration order. `QualifiedName` keeps two sets that share a member +name apart in one log or one client. `ToString()` is the name, unless the set writes its own, which +is kept, for a label, while the name stays what JSON, parsing and the column use. + +## Deciding by member + +`Match` takes one delegate per member, each named after it, and a member added later fails every +call until it is handled, which a `switch` over an `enum` never does: + +```csharp +string label = shipping.Match( + standard: () => "Ships within a week", + express: () => "Arrives tomorrow", + pickup: () => "Ready at the counter"); +``` + +Every argument is named. By position, they would follow the declaration order, and reordering two +members would swap their branches without a compile error, so the analyzer reports an argument by +position (CMTK0012), and its code fix names them. A member whose lower-cased name is a keyword is +escaped where it is written: a member `New` is `@new:`. `Match` refuses a null delegate before it +picks a branch. Where every member's answer is data, a property on the member says it once, as `Fee` +does, and no call has to list the members at all. + +## On the wire + +```csharp +public sealed record Quote(Shipping Method, Dictionary Fees); +``` + +```csharp +string json = JsonSerializer.Serialize(new Quote(Shipping.Express, new() { [Shipping.Standard] = 0m, [Shipping.Express] = 9.90m })); // {"Method":"Express","Fees":{"Standard":0,"Express":9.90}} +JsonSerializer.Deserialize("""{"Method":"Drone","Fees":{}}"""); // JsonException naming Shipping and member, never the name +``` + +A member is its name, as a value and as a dictionary key, and reads back as the member itself. No +naming policy renames it, since a renamed name would not read back. A JSON `null` reads as `null`, +as for any class, unless the serializer's `RespectNullableAnnotations` refuses it for a property that +is not nullable. In the OpenAPI document, with `AddTypeKit()`, a set is a string enum of its names, +`{"enum":["Standard","Express","Pickup"],"type":"string"}`, with nothing registered per set. A +minimal-API route or query parameter binds through the generated `TryParse`, and an unknown name is +a 400; a set declared in the same project as the endpoint trips ASP0020 on a route parameter, as a +value object does +([why, and the pragma](../src/CodoMetis.TypeKit.AspNetCore/README.md#a-route-parameter-declared-in-the-same-project)). + +## In the database + +```csharp +public sealed class Order +{ + public int Id { get; set; } + public Shipping Method { get; set; } = Shipping.Standard; + public List Offered { get; set; } = []; +} +``` + +```csharp +db.Orders.Where(o => o.Method == Shipping.Express); // WHERE o."Method" = 'Express' +``` + +With `UseTypeKit()`, a set is a text column of the member's name, in a table, in a primitive +collection of names (`text[]` on PostgreSQL, a JSON array elsewhere) and in a JSON column, with +nothing registered per set. A name the +set no longer has throws when it is read, rather than reading as null, and an instance that is no +member throws when it is written. Renaming a member renames what is stored, so the column's values +migrate with it. `ClosedSetConverter` names the converter for an explicit configuration. A +compiled model and Native AOT map a set as they map a value object, with the EF Core limitations the +[satellite's README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#compiled-models-and-native-aot) +lists. + +## Configuration and parsing + +```csharp +public sealed class CheckoutOptions +{ + public Shipping Default { get; set; } = Shipping.Standard; +} +``` + +```csharp +IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Checkout:Default"] = "Express" }).Build(); +string chosen = configuration.GetSection("Checkout").Get()!.Default.Name; // "Express" +``` + +The configuration binder reads a set through its generated type converter, as MVC and anything else +that reads a string through `TypeDescriptor` does. The binder's source generator, which Native AOT +turns on, reads no type converter: there, bind the name as a `string` and `Parse` it, as +[Native AOT](native-aot.md#configuration-binding) shows for a value object. A set also implements +`IParsable`, explicitly, so generic code parses one through `T.Parse`. + +## In logs + +`ToString()` is the name, so Microsoft.Extensions.Logging logs a member as `Express`. Serilog +destructures an object by its public properties, so `{@Quote}` would log a member's `Days` and `Fee` +with it; `Destructure.WithTypeKit()` (CodoMetis.TypeKit.Serilog) logs a member as what it prints, its +name or its own `ToString()`, as it does a value object ([Logging and secrets](logging-and-secrets.md)). + +## What the generators refuse + +A declaration they cannot complete fails the build, naming it; the generators' +[Build errors](../src/CodoMetis.TypeKit.Generators/README.md#build-errors) table has the full text. + +| The set | Error | +|---|---| +| is not `partial` | CMTK1013 | +| is not `sealed`: a set with subtypes is not closed | CMTK1014 | +| declares no member | CMTK1015 | +| has a constructor that is not private | CMTK1016 | +| has another public static member of its own type: a property, or a field that is not `readonly` | CMTK1017 | +| is generic, or nested in a generic type | CMTK1018 | +| has a member not created with `new` where it is declared: `Legacy = Open` would give one instance two names | CMTK1019 | +| is a record: `with` would copy a member into an instance that is none | CMTK1020 | +| has a member named as a generated member, or two whose `Match` parameters coincide (`Open`, `open`) | CMTK1021 | +| writes a generated member by hand, `ToString()` apart | CMTK1011 | +| is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | + +The set's own code can still call its private constructor beside its members, and such an instance +has no name: `Name`, `ToString()`, `Match`, JSON and the column throw for it, rather than carry a +null ([why](../src/CodoMetis.TypeKit/README.md#an-instance-that-is-not-a-member-of-its-set)). + +## When an `enum` is still right + +- **Flags.** A combination of members, `Read | Write`, is an `enum`'s; a closed set's instance is + exactly one member. +- **A number another system defines**, a protocol code or a column you do not own: a set stores and + sends its names. +- **Data that varies per occurrence.** A member's data is constant, declared once; a refusal that + carries what was refused (`OutsidePeriod(date)`) is a discriminated union's job, which C# 15 unions + take up, a question for 3.0.0. diff --git a/docs/merge-patches.md b/docs/merge-patches.md new file mode 100644 index 0000000..5d045ed --- /dev/null +++ b/docs/merge-patches.md @@ -0,0 +1,171 @@ +# Merge patches + +A PATCH endpoint that takes JSON Merge Patch (RFC 7396) reads each field as one of three things: not +mentioned, so it stays as it is; `null`, so it is cleared; or a value, which replaces it. +System.Text.Json reads a missing property and a `null` alike, because it calls no converter for a +property it does not find, so every such endpoint rebuilt the difference by hand. `Patch` is a +field that keeps the three apart. The [base README](../src/CodoMetis.TypeKit/README.md#merge-patches) +is its reference; this page is about using it: reading a body, applying it, the value objects inside +it, writing one, the OpenAPI document and source-generated contexts. The examples import +`CodoMetis.TypeKit`, `System.Text.Json` and `System.Text.Json.Serialization`. + +## Three states + +```csharp +public enum EmailFault { NoAt } + +public sealed partial record Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); +``` + +```csharp +ContactPatch patch = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; +PatchState email = patch.Email.State; // Absent, not in the document +PatchState phone = patch.Phone.State; // Null, a clear +PatchState floor = patch.Floor.State; // Value +``` + +| In the document | `State` | `Match` calls | `TryGetValue` | `IfValue` runs | `IfPresent` runs | `ToOption()` | +|---|---|---|---|---|---|---| +| not mentioned | `Absent` | `onAbsent` | false | no | no | `None` | +| `null` | `Null` | `onNull` | false | no | with `null` | `None` | +| a value | `Value` | `onValue` | true | with the value | with the value | `Some` | + +`default(Patch)` is `Absent`: the serializer never touches a property the document leaves out, so +it keeps the struct's default, and the analyzers, which refuse a `default` of every other struct in +this package, leave a patch alone. Reading never refuses a `null`, whatever `T` is: whether a clear +is accepted is the application's rule. `ToString()` prints the state and never the value. + +## Applying one + +Each field's own rule says what a clear means. A required field refuses one, an optional one is +cleared by it, and a field of a value type receives the clear as `null`, never as `0`: + +```csharp +public sealed class Contact +{ + public required Email Email { get; set; } + public string? Phone { get; set; } + public int? Floor { get; set; } +} + +public enum ContactFault { EmailRequired } + +public static class ContactPatching +{ + public static Result Apply(this Contact contact, ContactPatch patch) + { + if (patch.Email.State == PatchState.Null) return ContactFault.EmailRequired; // a required field refuses a clear + + patch.Email.IfValue(email => contact.Email = email); + patch.Phone.IfPresent(phone => contact.Phone = phone); // an optional one: null clears it + patch.Floor.IfPresent(floor => contact.Floor = floor); // a struct's clear arrives as int?, null, never as 0 + return Result.Success(); + } +} +``` + +```csharp +var contact = new Contact { Email = Email.FromKnownGood("ann@example.com"), Phone = "555-0100", Floor = 2 }; +ContactPatch clearPhone = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; +ContactPatch clearEmail = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; + +Result applied = contact.Apply(clearPhone); // Success +string? phone = contact.Phone; // null, cleared +int? floor = contact.Floor; // 3 +Result refused = contact.Apply(clearEmail); // Error: EmailRequired +``` + +`IfValue` runs only for a value, for a target a clear cannot reach; `IfPresent` runs for a value and +for a clear; `Match` takes all three, for a rule that has to say what each means, and `ToOption()` +is for a caller to whom a clear and an absence are the same. Refusing a clear of a required field, +as `Apply` does, says so to the client; ignoring it with `IfValue` alone would accept the request and +do less than it asked. + +## A value object inside a patch + +A patch reads its value with the value object's own converter, so a value object inside one keeps its +rule: a value `Create` refuses is refused as anywhere else, and the refusal does not quote it. + +```csharp +JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web); // JsonException naming Email and NoAt, never the text +``` + +In an endpoint, that is the 400 problem details any body a value object refuses gets. A `null` is +not a refusal but the `Null` state, which `Apply` turns into a fault of its own, so a `Patch` +is never needed. + +## Writing one + +A client, or a test, writes a patch body with the same type. A converter cannot leave its property +out of the document, so an absent patch is refused when it is written, unless the property says to +skip a default: + +```csharp +public sealed record ContactChange( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Floor); +``` + +```csharp +string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} +string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} +JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault +``` + +`Patch.Value(null)` is the `Null` state, so a client that holds a nullable value can pass it as it is. + +## In an endpoint and in the document + +```csharp +builder.Services.AddOpenApi(options => options.AddTypeKit()); +app.MapPatch("/contacts/{id}", (int id, ContactPatch patch) => TypedResults.NoContent()); +``` + +The body binds as any other: each property of `ContactPatch` is read by its patch, and one the +request leaves out is `Absent`. With `AddTypeKit()`, the document describes a patch property as its +argument's schema, inlined rather than as a component of its own, admitting `null`, and never +required, in OpenAPI 3.1 and 3.0. A value object inside a patch is described through the type it +wraps, so `email` is a `string`. This is not ASP.NET Core's JSON Patch (RFC 6902, +`application/json-patch+json`), which is a list of operations. + +## Source-generated contexts and Native AOT + +The patch's converter reads `T` itself, so a source-generated context never sees through a patch to +what it holds. A primitive, a `string`, a `Guid`, a date or a `Uri` is read with the serializer's +built-in converter and a value object with its own; any other type, a class or an enum, is listed in +the context, or the serializer refuses with its own message naming it. + +```csharp +public sealed record Address(string Street, string City); + +public sealed record MovePatch(Patch
Address, Patch Floor); + +[JsonSerializable(typeof(MovePatch))] +[JsonSerializable(typeof(Address))] // a class inside a patch is listed: the generator never sees through the patch +internal partial class MoveJson : JsonSerializerContext; +``` + +```csharp +MovePatch move = JsonSerializer.Deserialize("""{"Address":{"Street":"Main 1","City":"Bern"}}""", MoveJson.Default.MovePatch)!; +string city = move.Address.Match(address => address.City, () => "cleared", () => "unchanged"); // "Bern" +string floor = move.Floor.Match(f => f.ToString(), () => "cleared", () => "unchanged"); // "unchanged" +``` + +The same holds under Native AOT, which the consumer smoke test publishes: the converter for each +closed `Patch` is registered by that type's own static constructor, since a converter built by +reflection has no code there. + +## What it is not + +- **Not a deep merge.** Each field is its own patch, so `Patch
` replaces the whole address. + For RFC 7396's merge of a nested object, its properties are patches too: `Patch`. +- **Not an `Option`.** An option says whether a value exists, and refuses JSON; a patch says whether + a request mentioned a field, and is a wire type. `ToOption()` crosses from one to the other. +- **Not a model.** A patch is the shape of a request; the entity it is applied to keeps its own + types, `int?` for a floor, and its own rules. diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index b02e7ba..02f716a 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -94,8 +94,8 @@ properties and not an `Amount` that wraps one. Configure the value object instea `configurationBuilder.Properties().HavePrecision(18, 2)`. **Closed sets.** A closed set (`IClosedSet`) is a text column of the member's name, read back -as the member itself, in a table, a primitive collection (a JSON array of names) and a JSON column -alike, with nothing registered per set. A name the set no longer has throws when it is read, rather +as the member itself, in a table, a primitive collection of names (`text[]` on PostgreSQL, a JSON +array elsewhere) and a JSON column alike, with nothing registered per set. A name the set no longer has throws when it is read, rather than reading as null, and an instance that is no member throws when it is written. `ClosedSetConverter` names the converter for an explicit configuration. diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs new file mode 100644 index 0000000..f2b23b1 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs @@ -0,0 +1,208 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodoMetis.TypeKit.ClosedSets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Docs.ClosedSets; + +// sample: closed-sets/declaring +public sealed partial class Shipping : IClosedSet +{ + public static readonly Shipping Standard = new(days: 5, fee: 0m); + public static readonly Shipping Express = new(days: 1, fee: 9.90m); + public static readonly Shipping Pickup = new(days: 0, fee: 0m); + + private Shipping(int days, decimal fee) { Days = days; Fee = fee; } + + public int Days { get; } + public decimal Fee { get; } + + public DateOnly ArrivesBy(DateOnly ordered) => ordered.AddDays(Days); +} +// end sample + +// sample: closed-sets/wire +public sealed record Quote(Shipping Method, Dictionary Fees); +// end sample + +// sample: closed-sets/database +public sealed class Order +{ + public int Id { get; set; } + public Shipping Method { get; set; } = Shipping.Standard; + public List Offered { get; set; } = []; +} +// end sample + +public sealed class ShopDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); +} + +// sample: closed-sets/configuration +public sealed class CheckoutOptions +{ + public Shipping Default { get; set; } = Shipping.Standard; +} +// end sample + +public static class Usage +{ + public static (string Name, string Qualified, string All, bool Same, bool Known) Members() + { + // sample: closed-sets/members + string name = Shipping.Express.Name; // "Express" + string qualified = Shipping.Express.QualifiedName; // "Shipping.Express" + string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" + bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons + bool known = Shipping.TryParse("Drone", out _); // false, names compare ordinally + // end sample + + return (name, qualified, all, same, known); + } + + /// The block's last line, which throws: the same region, apart, so the lines above can run. + public static void ParseRefused() + { + // sample: closed-sets/members + Shipping.Parse("Drone"); // FormatException naming Shipping and member, never the name + // end sample + } + + public static string Label(Shipping shipping) + { + // sample: closed-sets/match + string label = shipping.Match( + standard: () => "Ships within a week", + express: () => "Arrives tomorrow", + pickup: () => "Ready at the counter"); + // end sample + + return label; + } + + public static string Wire() + { + // sample: closed-sets/wire-use + string json = JsonSerializer.Serialize(new Quote(Shipping.Express, new() { [Shipping.Standard] = 0m, [Shipping.Express] = 9.90m })); // {"Method":"Express","Fees":{"Standard":0,"Express":9.90}} + // end sample + + return json; + } + + public static void WireRefused() + { + // sample: closed-sets/wire-use + JsonSerializer.Deserialize("""{"Method":"Drone","Fees":{}}"""); // JsonException naming Shipping and member, never the name + // end sample + } + + public static IQueryable Query(ShopDb db) => + // sample: closed-sets/database-use + db.Orders.Where(o => o.Method == Shipping.Express); // WHERE o."Method" = 'Express' + // end sample + + public static string Configured() + { + // sample: closed-sets/configuration-use + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Checkout:Default"] = "Express" }).Build(); + string chosen = configuration.GetSection("Checkout").Get()!.Default.Name; // "Express" + // end sample + + return chosen; + } +} + +public sealed class ClosedSetsTests +{ + private const string Page = "docs/closed-sets.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Lower(bool value) => value ? "true" : "false"; + + [Fact] + public void A_set_answers_what_the_comments_say() + { + var (name, qualified, all, same, known) = Usage.Members(); + + name.ShouldBeShownIn(Page, Quoted, "Shipping.Express.Name"); + qualified.ShouldBeShownIn(Page, Quoted, "Shipping.Express.QualifiedName"); + all.ShouldBeShownIn(Page, Quoted, "string.Join(\" \", Shipping.All.Select(s => s.Name))"); + same.ShouldBeShownIn(Page, Lower, "Shipping.Parse(\"Express\") == Shipping.Express"); + known.ShouldBeShownIn(Page, Lower, "Shipping.TryParse(\"Drone\", out _)"); + SampleOutputs.ShouldRefuseAsShownIn(() => Shipping.Parse("Drone"), Page, input: "Drone"); + Should.Throw(Usage.ParseRefused); + + Shipping.Express.ArrivesBy(new DateOnly(2026, 10, 13)).ShouldBe(new DateOnly(2026, 10, 14)); + } + + [Fact] + public void Match_picks_the_member_s_branch() + { + Usage.Label(Shipping.Express).ShouldBe("Arrives tomorrow"); + Usage.Label(Shipping.Pickup).ShouldBe("Ready at the counter"); + } + + [Fact] + public void On_the_wire_a_member_is_its_name_as_the_comments_say() + { + Usage.Wire().ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new Quote(Shipping.Express, new() { [Shipping.Standard] = 0m, [Shipping.Express] = 9.90m }))"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("""{"Method":"Drone","Fees":{}}"""), Page, input: "Drone"); + Should.Throw(Usage.WireRefused); + + var back = JsonSerializer.Deserialize(Usage.Wire())!; + ReferenceEquals(back.Method, Shipping.Express).ShouldBeTrue(); + back.Fees[Shipping.Express].ShouldBe(9.90m); + } + + [Fact] + public void The_query_is_the_SQL_the_comment_says() + { + using var db = new ShopDb(SampleOutputs.NpgsqlWithoutServer()); + + db.Orders.Where(o => o.Method == Shipping.Express).ShouldBeTheSqlShownIn(Page); + db.Model.FindEntityType(typeof(Order))!.FindProperty(nameof(Order.Method))!.GetColumnType().ShouldBe("text"); + db.Model.FindEntityType(typeof(Order))!.FindProperty(nameof(Order.Offered))!.GetColumnType().ShouldBe("text[]", "a primitive collection of names: text[] on PostgreSQL"); + } + + [Fact] + public void Configuration_binds_a_set_as_the_comment_says() => + Usage.Configured().ShouldBeShownIn(Page, Quoted, "configuration.GetSection(\"Checkout\").Get()!.Default.Name"); + + /// What the page says of the document: a string enum of the names, with nothing registered per set. + [Fact] + public async Task The_document_describes_a_set_as_a_string_enum() + { + var document = await SampleOutputs.OpenApiDocumentAsync( + builder => builder.Services.AddOpenApi(options => options.AddTypeKit()), + app => app.MapPost("/quotes", (Quote quote) => quote.Method.Name)); + + document.Component(nameof(Shipping)).ToJsonString().ShouldBe("""{"enum":["Standard","Express","Pickup"],"type":"string"}"""); + } + + /// What the page says of a route: a minimal-API parameter binds through the generated TryParse, and an unknown name is a 400. + [Fact] + public async Task A_route_parameter_binds_by_name() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + await using var app = builder.Build(); +#pragma warning disable ASP0020 // Shipping has TryParse once generated: the route analyzer reads the source before weaving + app.MapGet("/delivery/{method}", (Shipping method) => method.Days); +#pragma warning restore ASP0020 + await app.StartAsync(TestContext.Current.CancellationToken); + + (await app.GetTestClient().GetFromJsonAsync("/delivery/Express", TestContext.Current.CancellationToken)).ShouldBe(1); + (await app.GetTestClient().GetAsync("/delivery/Drone", TestContext.Current.CancellationToken)).StatusCode.ShouldBe(System.Net.HttpStatusCode.BadRequest); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs new file mode 100644 index 0000000..1221574 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace ReadmeSamples.Docs.MergePatches; + +// sample: merge-patches/types +public enum EmailFault { NoAt } + +public sealed partial record Email : IValidatedValue +{ + public static Result Create(string value) => + value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); +} + +public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); +// end sample + +// sample: merge-patches/apply +public sealed class Contact +{ + public required Email Email { get; set; } + public string? Phone { get; set; } + public int? Floor { get; set; } +} + +public enum ContactFault { EmailRequired } + +public static class ContactPatching +{ + public static Result Apply(this Contact contact, ContactPatch patch) + { + if (patch.Email.State == PatchState.Null) return ContactFault.EmailRequired; // a required field refuses a clear + + patch.Email.IfValue(email => contact.Email = email); + patch.Phone.IfPresent(phone => contact.Phone = phone); // an optional one: null clears it + patch.Floor.IfPresent(floor => contact.Floor = floor); // a struct's clear arrives as int?, null, never as 0 + return Result.Success(); + } +} +// end sample + +// sample: merge-patches/writing +public sealed record ContactChange( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Floor); +// end sample + +// sample: merge-patches/context +public sealed record Address(string Street, string City); + +public sealed record MovePatch(Patch
Address, Patch Floor); + +[JsonSerializable(typeof(MovePatch))] +[JsonSerializable(typeof(Address))] // a class inside a patch is listed: the generator never sees through the patch +internal partial class MoveJson : JsonSerializerContext; +// end sample + +/// The same context without , for the refusal the page describes. +[JsonSerializable(typeof(MovePatch))] +internal partial class MoveJsonWithoutAddress : JsonSerializerContext; + +public static class Usage +{ + public static (PatchState Email, PatchState Phone, PatchState Floor) Read() + { + // sample: merge-patches/read + ContactPatch patch = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; + PatchState email = patch.Email.State; // Absent, not in the document + PatchState phone = patch.Phone.State; // Null, a clear + PatchState floor = patch.Floor.State; // Value + // end sample + + return (email, phone, floor); + } + + public static (Result Applied, string? Phone, int? Floor, Result Refused, Contact Contact) Apply() + { + // sample: merge-patches/apply-use + var contact = new Contact { Email = Email.FromKnownGood("ann@example.com"), Phone = "555-0100", Floor = 2 }; + ContactPatch clearPhone = JsonSerializer.Deserialize("""{"phone":null,"floor":3}""", JsonSerializerOptions.Web)!; + ContactPatch clearEmail = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; + + Result applied = contact.Apply(clearPhone); // Success + string? phone = contact.Phone; // null, cleared + int? floor = contact.Floor; // 3 + Result refused = contact.Apply(clearEmail); // Error: EmailRequired + // end sample + + return (applied, phone, floor, refused, contact); + } + + public static void Refused() + { + // sample: merge-patches/value-object + JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web); // JsonException naming Email and NoAt, never the text + // end sample + } + + public static (string Clear, string Set) Write() + { + // sample: merge-patches/writing-use + string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} + string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} + // end sample + + return (clear, set); + } + + /// The block's last line, which throws: the same region, apart, so the lines above can run. + public static void WriteRefused() + { + // sample: merge-patches/writing-use + JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault + // end sample + } + + public static void Configure(WebApplicationBuilder builder) + { + // sample: merge-patches/endpoint + builder.Services.AddOpenApi(options => options.AddTypeKit()); + // end sample + } + + public static void Map(WebApplication app) + { + // sample: merge-patches/endpoint + app.MapPatch("/contacts/{id}", (int id, ContactPatch patch) => TypedResults.NoContent()); + // end sample + } + + public static (string City, string Floor) Context() + { + // sample: merge-patches/context-use + MovePatch move = JsonSerializer.Deserialize("""{"Address":{"Street":"Main 1","City":"Bern"}}""", MoveJson.Default.MovePatch)!; + string city = move.Address.Match(address => address.City, () => "cleared", () => "unchanged"); // "Bern" + string floor = move.Floor.Match(f => f.ToString(), () => "cleared", () => "unchanged"); // "unchanged" + // end sample + + return (city, floor); + } +} + +public sealed class MergePatchesTests +{ + private const string Page = "docs/merge-patches.md"; + + private static string Quoted(string text) => $"\"{text}\""; + + private static string Outcome(Result result) => result.Match(() => "Success", fault => $"Error: {fault}"); + + [Fact] + public void A_body_reads_as_the_three_states_the_comments_say() + { + var (email, phone, floor) = Usage.Read(); + + email.ShouldBeShownIn(Page, state => state.ToString(), "patch.Email.State"); + phone.ShouldBeShownIn(Page, state => state.ToString(), "patch.Phone.State"); + floor.ShouldBeShownIn(Page, state => state.ToString(), "patch.Floor.State"); + } + + [Fact] + public void Applying_a_patch_does_what_the_comments_say() + { + var (applied, phone, floor, refused, contact) = Usage.Apply(); + + applied.ShouldBeShownIn(Page, Outcome, "contact.Apply(clearPhone)"); + phone.ShouldBeShownIn(Page, p => p ?? "null", "contact.Phone"); + floor.ShouldBeShownIn(Page, f => f?.ToString() ?? "null", "contact.Floor"); + refused.ShouldBeShownIn(Page, Outcome, "contact.Apply(clearEmail)"); + + contact.Email.Value.ShouldBe("ann@example.com", "an absent field leaves the value as it is"); + } + + /// A struct's clear is null, not its default: IfPresent hands a Patch<int>'s clear over as int?. + [Fact] + public void A_cleared_struct_field_is_null_not_zero() + { + var contact = new Contact { Email = Email.FromKnownGood("a@b"), Floor = 2 }; + + _ = contact.Apply(JsonSerializer.Deserialize("""{"floor":null}""", JsonSerializerOptions.Web)!); + + contact.Floor.ShouldBeNull(); + } + + [Fact] + public void A_value_object_inside_a_patch_keeps_its_rule() + { + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web), Page, input: "nobody"); + Should.Throw(Usage.Refused); + } + + [Fact] + public void Writing_a_patch_answers_what_the_comments_say() + { + var (clear, set) = Usage.Write(); + + clear.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web)"); + set.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web)"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))), Page, input: "555"); + Should.Throw(Usage.WriteRefused); + } + + /// What the page says of the document: every patch property inlined as its argument's schema, admitting null, none required, an Email as the string it wraps. + [Fact] + public async Task The_document_describes_a_patch_as_the_page_says() + { + var document = await SampleOutputs.OpenApiDocumentAsync(Usage.Configure, Usage.Map); + var patch = document.Component(nameof(ContactPatch)); + + patch["required"].ShouldBeNull(patch.ToJsonString()); + foreach (var (name, type) in new[] { ("email", "string"), ("phone", "string"), ("floor", "integer") }) + { + var property = patch["properties"]![name]!; + property["$ref"].ShouldBeNull($"{name} is inlined: {property.ToJsonString()}"); + // The argument's own schema, admitting null: ASP.NET's web defaults describe an int as ["integer","string"]. + var types = property["type"]!.AsArray().Select(node => node!.GetValue()).ToList(); + types.ShouldContain("null", $"{name}: {property.ToJsonString()}"); + types.ShouldContain(type, $"{name}: {property.ToJsonString()}"); + } + } + + [Fact] + public void A_source_generated_context_reads_a_patch_as_the_comments_say() + { + var (city, floor) = Usage.Context(); + + city.ShouldBeShownIn(Page, Quoted, "move.Address.Match(address => address.City, () => \"cleared\", () => \"unchanged\")"); + floor.ShouldBeShownIn(Page, Quoted, "move.Floor.Match(f => f.ToString(), () => \"cleared\", () => \"unchanged\")"); + + // Without the class listed, the serializer refuses with its own message naming it, as the page says. + Should.Throw(() => JsonSerializer.Deserialize("""{"Address":{"Street":"Main 1","City":"Bern"}}""", MoveJsonWithoutAddress.Default.MovePatch)) + .Message.ShouldContain("Address"); + } +} From ccd378702aff41b5cfe930823427c9891543ec71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 15:57:56 +0200 Subject: [PATCH 026/147] feat: fault responses a client can act on, and a closed set as the fault type Step 8 of 2.0.0, after spikes/FaultResponses, run and answered: the candidate was the satellite itself, and its thirteen claims are tests here and in the smoke test's Native AOT consumer. CodoMetis.TypeKit: FaultKind, IFault and IFaultSet, a closed set whose members carry a kind and an optional description; and Fault, a reason with a sentence about one occurrence, refused by JSON and, when default, by CMTK0001. CodoMetis.TypeKit.AspNetCore, each part opt-in and decision 38's default response unchanged: - one internal FaultType, so an enum and a closed set take the same path through the mapping, the startup check, the document and the response; - AddFaultResponses() answering every member by its kind (404, 409, 422, 403, 503), its description as the detail; a FaultResponse per member with a type and a title, listed in the document; - Fault's detail as the problem's detail, keyed on the reason's type; - WithFaultStatus on an endpoint and [FaultStatus] on an MVC action, for that endpoint's response and document entry; the startup check refuses a status for a fault type the endpoint does not answer with, and an attribute naming no member; - ToHttpResult(success), the endpoint's own success such as Created, its TSuccess annotated as ASP.NET annotates Results' (IL2091), not suppressed; - IFaultFeature on the request while the problem is written, and FaultResponseOptions.QualifiedFaultNames. FaultProblem takes notnull where it took struct, Enum: the first difference the 1.5.1 baseline reports (CP0021), suppressed alone so any other break still fails the pack (decision 48). Design choices in decision 49. Seeded: the endpoint override, the feature's removal, a kind's status, the qualified names, the detail, the stray-status check and the document's problem types, each failing its test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 + design/plan.md | 40 +- docs/aspnet-core.md | 7 + docs/closed-sets.md | 12 + spikes/FaultResponses/README.md | 80 ++-- .../CompatibilitySuppressions.xml | 11 + .../FaultProblem.cs | 87 +++- .../FaultResponse.cs | 87 ++++ .../FaultResponses.cs | 125 ++++-- .../FaultResponsesOperationTransformer.cs | 18 +- .../FaultResponsesStartupCheck.cs | 35 +- .../FaultStatus.cs | 138 +++++++ src/CodoMetis.TypeKit.AspNetCore/FaultType.cs | 70 ++++ src/CodoMetis.TypeKit.AspNetCore/README.md | 127 +++++- ...ultResponsesServiceCollectionExtensions.cs | 89 ++++- .../TypeKitHttpResultExtensions.cs | 141 ++++++- src/CodoMetis.TypeKit.Generators/README.md | 4 +- src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs | 73 ++++ src/CodoMetis.TypeKit/Fault.cs | 62 +++ .../NotWireTypeJsonConverterFactory.cs | 6 +- src/CodoMetis.TypeKit/README.md | 8 + .../FaultResponsePartsTests.cs | 370 ++++++++++++++++++ .../Probes.cs | 32 ++ .../GeneratedSurface.verified.txt | 39 ++ .../AspNetCore/FaultResponseSamples.cs | 210 ++++++++++ test/consumer-smoke-test.sh | 43 ++ 26 files changed, 1825 insertions(+), 104 deletions(-) create mode 100644 src/CodoMetis.TypeKit.AspNetCore/CompatibilitySuppressions.xml create mode 100644 src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs create mode 100644 src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs create mode 100644 src/CodoMetis.TypeKit.AspNetCore/FaultType.cs create mode 100644 src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs create mode 100644 src/CodoMetis.TypeKit/Fault.cs create mode 100644 test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs create mode 100644 test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1429290..4e65b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,19 @@ Semantic Versioning reserves for a major, listed under Changed as they land. outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete public surface, checked against the types. The package READMEs remain the reference for each package. The plan moved from `docs/plan.md` to `design/plan.md`. +- **Fault responses a client can act on.** In `CodoMetis.TypeKit`: `FaultKind`, `IFault` and + `IFaultSet`, a closed set whose members carry a kind and an optional description, and + `Fault`, a fault with a sentence about one occurrence (`Fault.Of(reason, detail)`). In + `CodoMetis.TypeKit.AspNetCore`, each opt-in, with decision 38's default response unchanged: a closed + set as the fault type, `AddFaultResponses()` answering every member by its kind (`NotFound` 404, + `Conflict` 409, `Invalid` 422, `Forbidden` 403, `Unavailable` 503) with its description as the + `detail`; a `FaultResponse` per member with a `type` and a `title`; a `Fault`'s detail as the + problem's `detail`; `WithFaultStatus(fault, status)` on an endpoint and `[FaultStatus]` on an + MVC action, for that endpoint's response and document entry; `ToHttpResult(success)`, the + endpoint's own success such as `Created`; and `IFaultFeature` on the request while the problem is + written, for the host's `CustomizeProblemDetails`, with `FaultResponseOptions.QualifiedFaultNames`. + The startup check refuses a status for a fault type the endpoint does not answer with, and an + attribute naming no member. ### Changed @@ -124,6 +137,8 @@ Semantic Versioning reserves for a major, listed under Changed as they land. `IEnumerable` ones read every row and ran it in memory. A predicate the provider cannot translate now throws where it ran in memory before. `LastOrNone` and `LastOrError` on a query are compile errors that say to order the query and take the first, or to call `AsEnumerable()`. +- **`CodoMetis.TypeKit.AspNetCore`**: `FaultProblem` takes any `notnull` fault type, an enum or a + closed set, where it took `struct, Enum`; every type argument it accepted is still accepted. ## 1.5.1 — 2026-10-11 diff --git a/design/plan.md b/design/plan.md index 5623853..b136b51 100644 --- a/design/plan.md +++ b/design/plan.md @@ -609,8 +609,16 @@ Each phase ends green, and its guards have been proven by seeding the defect `ValueTask` receiver); `Transpose`, `SingleOrNone` and `ElementAtOrNone` ✅ (more than one is a refusal, as `SingleOrDefault`'s; a negative index on a query is `None` without a query, since `Skip(-1)` skips nothing). - 8. **The fault responses**, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), - last, since they read a smart enum's kind. + 8. **The fault responses** ✅ 2026-10-13, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), + whose candidate was the satellite itself and whose thirteen claims are its tests and the smoke + test's AOT consumer. In the base package: `FaultKind`, `IFault` and `IFaultSet` (a kind + and an optional description per member), and `Fault` (a reason and a detail, refused + by JSON and by CMTK0001 when `default`). In the ASP.NET satellite: one internal `FaultType` + for an enum and a closed set alike, `FaultResponse` (status, `type`, `title`), + `AddFaultResponses()` by kind, `WithFaultStatus` and `[FaultStatus]`, + `ToHttpResult(success)`, `IFaultFeature` and `FaultResponseOptions.QualifiedFaultNames` + (decision 49). `FaultProblem`'s constraint relaxed to `notnull`, the first difference + the 1.5.1 baseline reports, suppressed alone (decision 48). 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict on each intended break. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test @@ -1498,6 +1506,34 @@ Still open: `[Obsolete(error: true)]`, as `Catch`'s task trap is, since a query has no last element until it is ordered. EF's `FirstOrNoneAsync` lives in the satellite, in EF's namespace; under Native AOT neither is precompiled (measured), which the EF README says with the call-site form to use. +48. **An intended break is suppressed alone; the baseline stays** (2026-10-13, phase 13). The plan + left the choice to the first break package validation reported, and it came with the fault + responses: `FaultProblem` takes `notnull` where it took `struct, Enum` (CP0021), so that a + closed set can be a fault type. Each intended break gets its own entry in the project's + `CompatibilitySuppressions.xml` (generated with `-p:ApiCompatGenerateSuppressionFile=true`, then + read before it is committed), and the 1.5.1 baseline keeps comparing every other member, so an + unintended break still fails the pack. Letting the baseline lapse for 2.0.0 would have passed one. +49. **Fault responses a client can act on** (2026-10-13, phase 13, spikes/FaultResponses). The five + parts ship as proposed, with these choices measured or made on the way: + - **A kind's status is fixed**: `NotFound` 404, `Conflict` 409, `Invalid` 422 (RFC 9110 + §15.5.21, a well-formed request a rule refuses), `Forbidden` 403, `Unavailable` 503. No + host-wide table: a mapping of the host's own, calling `FaultResponse.For(kind)` for the members + it keeps, is the escape hatch, as decision 38's switch is. `FaultKind` starts at 1, so a + `default` kind is refused where the set is mapped. + - **`Fault`'s fault is its `Reason`**, not its `Kind` as first proposed: a member's + `FaultKind` is its `Kind`, and `fault.Kind.Kind` would have read as a stutter. A blank detail is + refused; `null` says there is none. + - **A description is the `detail`**: RFC 9457 §3.1.4 has `detail` help the client correct the + problem, and §3.1.3 keeps `title` the same for every occurrence of a type, which a status's own + title already is. An occurrence's detail replaces the member's description. + - **`IFault` is non-generic**, beside `IFaultSet`, so the satellite reads a member's kind + with an interface check whatever the fault type, and `Description` defaults to `null`. + - **Overloads by constraint live in two classes**, since C# forbids two methods that differ in + constraints alone; with the type arguments inferred, the one whose constraint fails drops out. + - **The document lists problem types in the response's description**, and `fault` stays a string + on the shared problem schema; a per-operation enum is not offered. + - **An MVC action names its member in an attribute**, `[FaultStatus(nameof(…), 422)]`, + since an attribute takes only constants; a name no member has stops the host. ## 9. Option and Result diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md index fb2af08..230d1f7 100644 --- a/docs/aspnet-core.md +++ b/docs/aspnet-core.md @@ -141,6 +141,13 @@ host's own reaches these problems: builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["service"] = "customers"); ``` +A response can carry more, each part opt-in, so the default above stays as it is: a `type` and a +`title` per fault, a closed set as the fault type, answered by its members' kinds and descriptions, +a sentence per occurrence in a `Fault`, a status per endpoint, the endpoint's own success +such as a `201 Created`, and the fault on the request for a `CustomizeProblemDetails`. The README's +[Mapping faults to statuses](../src/CodoMetis.TypeKit.AspNetCore/README.md#mapping-faults-to-statuses) +and the sections after it show each. + ## The document With `AddTypeKit()`, every value object is the schema ASP.NET publishes for the type it wraps, diff --git a/docs/closed-sets.md b/docs/closed-sets.md index 30b448a..f797a5e 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -137,6 +137,18 @@ destructures an object by its public properties, so `{@Quote}` would log a membe with it; `Destructure.WithTypeKit()` (CodoMetis.TypeKit.Serilog) logs a member as what it prints, its name or its own `ToString()`, as it does a value object ([Logging and secrets](logging-and-secrets.md)). +## As a fault type + +A closed set whose members are faults declares `IFaultSet` in place of `IClosedSet`: +each member carries a `FaultKind`, `NotFound`, `Conflict`, `Invalid`, `Forbidden` or `Unavailable`, +nothing HTTP-specific, and optionally a `Description`, a sentence a person reads, both through its +private constructor. In CodoMetis.TypeKit.AspNetCore, `AddFaultResponses()` answers every +member by its kind, with its description as the problem's `detail`, so the domain names its faults +and the host maps nothing by hand +([the satellite's README](../src/CodoMetis.TypeKit.AspNetCore/README.md#a-closed-set-as-the-fault-type)). +Any other closed set is a fault type too, with a mapping of its own. What varies per occurrence +travels beside the member, as a `Fault` with a detail. + ## What the generators refuse A declaration they cannot complete fails the build, naming it; the generators' diff --git a/spikes/FaultResponses/README.md b/spikes/FaultResponses/README.md index 81ff766..0d5361b 100644 --- a/spikes/FaultResponses/README.md +++ b/spikes/FaultResponses/README.md @@ -1,8 +1,8 @@ # Spike: can a fault response carry what a client acts on, without giving up decision 38's checks? -**Status: outline, not yet run.** Proposed 2026-10-12, from the "Fault responses a client can act -on" part of phase 13 (2.0.0) in [design/plan.md](../../design/plan.md) §2. The fault's kind is no longer -a `FaultKind` attribute: it is a property of a smart-enum member, in the same phase. +**Status: run 2026-10-13, answered.** From the "Fault responses a client can act on" part of phase 13 +(2.0.0) in [design/plan.md](../../design/plan.md) §2. The fault's kind is no longer a `FaultKind` +attribute: it is a property of a closed-set member, in the same phase. **Question.** Decision 38's `ToHttpResult()` answers a fault with its status and its bare name, and a success with `200` or `204`. An application that shows a refusal to a person, creates resources, @@ -12,42 +12,64 @@ OpenAPI description as they are, under JIT and Native AOT, in minimal APIs and M 1. **`FaultResponse` per member**: status, `type` URI and `title`, from an `AddFaultResponses(Func)` overload. -2. **`Fault(TFault Kind, string? Detail)`**, a record struct in the base package, so a - sentence travels per occurrence while the mapping stays keyed on the enum. +2. **`Fault`**, a record struct in the base package, so a sentence travels per occurrence + while the mapping stays keyed on the fault type. 3. **`.WithFaultStatus(fault, status)`**, endpoint metadata overriding the host's status for one endpoint. 4. **`ToHttpResult(success)`**, the endpoint's own success result (`Created`, or an application result that sets an ETag) in `Results>`. 5. **`IFaultFeature`** on the request while the problem is written, so `CustomizeProblemDetails` - can add the host's own extensions, plus an option to qualify `fault` with the enum's name. + can add the host's own extensions, plus an option to qualify `fault` with its type's name. -**Answer: pending.** +**Answer: yes, all five, beside a closed set as a fault type answered by its members' kinds.** +Measured 2026-10-13 on .NET SDK 10.0.401, ASP.NET Core 10.0.12 and Microsoft.AspNetCore.OpenApi +10.0.12, under JIT in minimal APIs and MVC, and published with Native AOT by the consumer smoke test. +The candidate was built in `CodoMetis.TypeKit.AspNetCore` itself, since it is the satellite's shipped +shape, and every claim below is a test of it, so this directory holds no project of its own: the +evidence is `FaultResponsePartsTests` and `FaultResponseTests` (test/CodoMetis.TypeKit.AspNetCore.Tests), +`FaultResponseSampleTests` (the README's blocks, run) and the `aot` consumer of +`test/consumer-smoke-test.sh`. Each guard was seeded (the endpoint override ignored, the feature left +on the request, a kind's status changed, the qualified names ignored, the detail dropped, the stray +status check removed, the problem types dropped from the document), and each seed failed its test. -## The candidate (planned) +## What the measurement decided -The layout follows [spikes/ResultHttp](../ResultHttp/README.md): `Domain.cs` with no ASP.NET in it -(`OrderFault`, methods returning `Result` and `Result>`), -`Candidate.cs` as the satellite would ship it, and `Program.cs` running each part, writing the -documents to `out/` and printing every response body. A `--mvc` switch adds a controller, and an -`-p:Aot=true` publish repeats the minimal-API half under Native AOT. +- **One reading of a fault type.** `FaultType`, internal, holds the members and each one's + name, qualified name, kind and description; the mapping, the startup check, the document and the + response read nothing else. It is made by the registration overload whose constraint says what the + type is (`Enum.GetValues()` for an enum, `TSet.All` for a closed set), and a member's kind + is read through the base package's non-generic `IFault`, so nothing reflects. +- **Overloads by constraint, in two classes.** An enum's `AddFaultResponses` and `ToHttpResult` and a + closed set's take the same parameters and differ only in their constraints, which C# allows only + across classes; with the type arguments inferred or written, the candidate whose constraint fails + drops out (C# 7.3), so each call resolves without ambiguity. `Fault`'s overloads take a + receiver of another shape and live beside the enum's. +- **A kind's default status**: `NotFound` 404, `Conflict` 409, `Invalid` 422, `Forbidden` 403, + `Unavailable` 503, through `FaultResponse.For(kind)`. A host does not change the table; a mapping + of its own, which can call `FaultResponse.For` for the members it keeps, is the escape hatch, as + decision 38's switch is. `FaultKind` starts at 1, so a member whose kind is `default` is refused + where the set is mapped. +- **Where a sentence goes.** RFC 9457 §3.1.4: `detail` helps the client correct this occurrence. A + member's description is the `detail` by default, and a `Fault`'s detail replaces it; the + `title` is the problem type's summary, a `FaultResponse`'s or the status's own. +- **The constraint relaxed.** `FaultProblem` takes `notnull` where it took `struct, Enum`. + Package validation reports it (CP0021) though no closed type it accepted is refused now; it is the + first intended difference from the 1.5.1 baseline, suppressed alone (decision 48). ## Findings -Each row is a claim the spike must settle. A seed that breaks the guard proves each one, as in -`.claude/skills/verify-the-guard`. - | # | Claim | Result | |---|---|---| -| 1 | The `FaultResponse` overload checks every member at registration, as the `int` one does, and refuses a status outside 400 to 599 | | -| 2 | A `type` URI and `title` reach the body and the operation's problem response, and nothing of the input does | | -| 3 | `ToHttpResult()` over `Result>` and over `Result` both resolve without type arguments, with no ambiguity | | -| 4 | `Detail` reaches the body only when set, and the default response is byte-identical to 1.5.1's | | -| 5 | `WithFaultStatus` changes the status for that endpoint only, in the response and in that operation's document entry | | -| 6 | The startup check refuses a `WithFaultStatus` naming a fault type the endpoint does not answer with | | -| 7 | MVC: the per-endpoint metadata reaches the operation transformer (ResultHttp finding 4 found `PopulateMetadata` handed an empty service provider) | | -| 8 | `ToHttpResult(success)` describes `201` with its body, and a `Location` header is written | | -| 9 | `CustomizeProblemDetails` sees `IFaultFeature` for a problem `ToHttpResult()` wrote, and not for any other problem | | -| 10 | Qualified `fault` names are opt-in; the default stays the bare member name | | -| 11 | `fault` stays a string on the shared `ProblemDetails` component; a per-operation enum, if offered, is opt-in and never a union across fault types | | -| 12 | The mapping, the startup check and the document read a fault type through one internal abstraction, so an enum fault and a smart-enum fault take the same path | | -| 13 | Every part runs under Native AOT with no new trim or AOT warning | | +| 1 | The `FaultResponse` overload checks every member at registration, as the `int` one does, and refuses a status outside 400 to 599 | measured: `A_response_per_member_is_checked_where_it_is_mapped` (200, 302, 600 refused, naming `OrderFault.NameTaken` and `responseOf`); a kind that is no `FaultKind` refused naming the member | +| 2 | A `type` URI and `title` reach the body and the operation's problem response, and nothing of the input does | measured: the body carries both; the response's description lists the problem types answered with that status (`Conflict. Problem types: …`) | +| 3 | `ToHttpResult()` over `Result>`, over `Result` and over a closed set's resolves without type arguments, with no ambiguity | measured: the tests call each without type arguments and compile | +| 4 | `Detail` reaches the body only when set, and the default response is unchanged from 1.5.1 | measured: the default body's members are 1.5.1's (`fault`, `status`, `title`, `traceId`, `type`), and decision 38's tests pass unchanged; `detail` appears for a `Fault` with one and a set member with a description, never for one without | +| 5 | `WithFaultStatus` changes the status for that endpoint only, in the response and in that operation's document entry | measured: 422 on the endpoint, 409 elsewhere; the endpoint's operation lists 422 in place of 409 | +| 6 | The startup check refuses a `WithFaultStatus` naming a fault type the endpoint does not answer with | measured; and an MVC attribute naming no member | +| 7 | MVC: the per-endpoint metadata reaches the operation transformer (ResultHttp finding 4 found `PopulateMetadata` handed an empty service provider) | measured: `[FaultStatus(nameof(OrderFault.NameTaken), 422)]` on an action changes its response and its operation; the transformer reads it from the endpoint's metadata, with the services from the document's context | +| 8 | `ToHttpResult(success)` describes `201` with its body, and a `Location` header is written | measured, minimal API and the smoke test's AOT binary; `Results` needs `TSuccess` annotated as ASP.NET annotates `TResult1`, which the trim analyzer said (IL2091) and the annotation answers, without a suppression | +| 9 | `CustomizeProblemDetails` sees `IFaultFeature` for a problem `ToHttpResult()` wrote, and not for any other problem | measured: present for an enum fault, a `Fault` and a set's member, absent for `TypedResults.Problem` elsewhere and on the request once the problem is written | +| 10 | Qualified `fault` names are opt-in; the default stays the bare member name | measured: `FaultResponseOptions.QualifiedFaultNames` gives `OrderFault.NotFound`, the default `NotFound` | +| 11 | `fault` stays a string on the shared `ProblemDetails` component; a per-operation enum, if offered, is opt-in and never a union across fault types | measured: the component names no fault and no `enum`; no per-operation enum is offered in 2.0.0 | +| 12 | The mapping, the startup check and the document read a fault type through one internal abstraction, so an enum fault and a smart-enum fault take the same path | built so (`FaultType`), and measured by a closed set answered by kind end to end, its document listing 403, 404 and 409 | +| 13 | Every part runs under Native AOT with no new trim or AOT warning | measured: the smoke test's `aot` web consumer publishes a fault set answered by kind, a `WithFaultStatus`, a `Fault` with a detail, a `Created` success and a `CustomizeProblemDetails` reading the feature, with no trim or AOT warning naming a package or woven code | diff --git a/src/CodoMetis.TypeKit.AspNetCore/CompatibilitySuppressions.xml b/src/CodoMetis.TypeKit.AspNetCore/CompatibilitySuppressions.xml new file mode 100644 index 0000000..c6ac70a --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/CompatibilitySuppressions.xml @@ -0,0 +1,11 @@ + + + + + CP0021 + T:CodoMetis.TypeKit.AspNetCore.FaultProblem`1``0:notnull + lib/net10.0/CodoMetis.TypeKit.AspNetCore.dll + lib/net10.0/CodoMetis.TypeKit.AspNetCore.dll + true + + \ No newline at end of file diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs index 6a8dce4..2eb67bf 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace CodoMetis.TypeKit.AspNetCore; @@ -14,35 +15,63 @@ namespace CodoMetis.TypeKit.AspNetCore; /// /// Made by ToHttpResult(), as the second case of the endpoint's /// . The status comes -/// from services.AddFaultResponses<TFault>(…), so the domain that declares the fault -/// never references ASP.NET. +/// from services.AddFaultResponses<TFault>(…), or from the endpoint's +/// WithFaultStatus, so the domain that declares the fault never references ASP.NET. /// /// /// The endpoint's OpenAPI operation lists a problem response for every status the fault type is -/// mapped to (AddTypeKit()), and a host with an endpoint whose fault type is not mapped does -/// not start. The problem names the fault, never anything of the input. +/// answered with (AddTypeKit()), and a host with an endpoint whose fault type is not mapped does +/// not start. The problem names the fault, and a type and title where the mapping gives +/// them; its detail is the occurrence's (), or else a fault set +/// member's description, and nothing else of the request reaches it. /// /// -/// The fault type, an enum. +/// The fault type, an enum or a closed set. public sealed class FaultProblem : IResult, IEndpointMetadataProvider - where TFault : struct, Enum + where TFault : notnull { - internal FaultProblem(TFault fault) => Fault = fault; + internal FaultProblem(TFault fault, string? detail = null) + { + Fault = fault; + Detail = detail; + } /// The fault this response reports. public TFault Fault { get; } + /// The sentence about this occurrence, where the result carried a with one. + public string? Detail { get; } + /// - public Task ExecuteAsync(HttpContext httpContext) + public async Task ExecuteAsync(HttpContext httpContext) { ArgumentNullException.ThrowIfNull(httpContext); var responses = httpContext.RequestServices.GetService>() ?? throw FaultResponses.Unmapped(); + var answer = responses.Of(Fault); + var status = httpContext.GetEndpoint()?.Metadata is { } metadata ? FaultStatuses.Override(metadata, Fault, responses.Type) ?? answer.Status : answer.Status; + var name = httpContext.RequestServices.GetService>()?.Value.QualifiedFaultNames == true ? answer.QualifiedName : answer.Name; + var detail = Detail ?? answer.Description; - return TypedResults.Problem( - statusCode: responses.StatusOf(Fault), - extensions: new Dictionary { ["fault"] = Fault.ToString() }) - .ExecuteAsync(httpContext); + // Present only while this problem is written, so a CustomizeProblemDetails that reads it sees it + // for this problem and for no other the request writes. + var features = httpContext.Features; + var previous = features.Get(); + features.Set(new FaultFeature(typeof(TFault), Fault, name, detail)); + try + { + await TypedResults.Problem( + statusCode: status, + type: answer.Type?.ToString(), + title: answer.Title, + detail: detail, + extensions: new Dictionary { ["fault"] = name }) + .ExecuteAsync(httpContext); + } + finally + { + features.Set(previous); + } } /// Records which fault type the endpoint answers with; the statuses are read where the services are. @@ -58,4 +87,38 @@ static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, Endpoi builder.Metadata.Add(FaultResponsesMetadata.Instance); } + + private sealed record FaultFeature(Type FaultType, object Fault, string Name, string? Detail) : IFaultFeature; +} + +/// +/// The fault a problem reports, on the request while ToHttpResult() writes it, so a host's +/// CustomizeProblemDetails can add what it needs, an error code, a link, without this package +/// naming any: context.HttpContext.Features.Get<IFaultFeature>(). +/// +/// Absent for every other problem the request writes, such as an exception handler's. +public interface IFaultFeature +{ + /// The fault type, an enum or a closed set. + Type FaultType { get; } + + /// The fault: the enum member or the closed set's member. + object Fault { get; } + + /// The name the problem's fault member carries, qualified where the host asks for it. + string Name { get; } + + /// The problem's detail, or . + string? Detail { get; } +} + +/// How ToHttpResult() writes a problem: services.Configure<FaultResponseOptions>(options => …). +public sealed class FaultResponseOptions +{ + /// + /// Whether the problem's fault member is qualified with its type, OrderFault.NotFound, + /// so two fault types that share a member name stay apart in one client. Off by default: the bare + /// member name, NotFound. + /// + public bool QualifiedFaultNames { get; set; } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs new file mode 100644 index 0000000..7f23b51 --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs @@ -0,0 +1,87 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// How a host answers one fault: its status, and optionally the problem's type and title. +/// +/// +/// +/// Returned for each member by the mapping given to AddFaultResponses: +/// fault => FaultResponse.Status(409).WithType(new Uri("https://example.com/problems/name-taken")). +/// A bare status converts, so fault => 404 is a response too. RFC 9457 makes type the +/// problem's primary identifier, which a client matches on, and title its summary; both name +/// the problem type and nothing of the request. Without them, ASP.NET writes the status's own. +/// +/// +/// The status is checked where the faults are mapped, with the member it belongs to named: it must +/// be an error status, 400 to 599. +/// +/// +public sealed class FaultResponse +{ + private FaultResponse(int statusCode, Uri? type, string? title) + { + StatusCode = statusCode; + Type = type; + Title = title; + } + + /// The HTTP status. + public int StatusCode { get; } + + /// The problem's type, or for the status's own. + public Uri? Type { get; } + + /// The problem's title, or for the status's own. + public string? Title { get; } + + /// A response with , and the status's own type and title. + /// The HTTP status, 400 to 599, checked where the faults are mapped. + /// The response. + public static FaultResponse Status(int statusCode) => new(statusCode, null, null); + + /// + /// The response a is answered with when no mapping says otherwise: + /// NotFound 404, Conflict 409, Invalid 422, Forbidden 403 and + /// Unavailable 503. + /// + /// + /// For a mapping that changes some members and keeps the rest: + /// fault => fault == OrderFault.Gone ? 410 : FaultResponse.For(fault.Kind). + /// + /// The kind. + /// The response. + /// is no member of , as a default one is not. + public static FaultResponse For(FaultKind kind) => Status(kind switch + { + FaultKind.NotFound => 404, + FaultKind.Conflict => 409, + FaultKind.Invalid => 422, + FaultKind.Forbidden => 403, + FaultKind.Unavailable => 503, + _ => throw new ArgumentOutOfRangeException(nameof(kind), $"{(int)kind} is no FaultKind: a fault set's member declares one of its members, and a default kind is none." + FaultRefusals.Mapping), + }); + + /// The same response with the problem's type, a URI a client matches on. + /// The problem type's URI. + /// A new response. + public FaultResponse WithType(Uri type) + { + ArgumentNullException.ThrowIfNull(type); + return new(StatusCode, type, Title); + } + + /// The same response with the problem's title, a summary of the problem type a person reads. + /// The title. Never blank. + /// A new response. + public FaultResponse WithTitle(string title) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + return new(StatusCode, Type, title); + } + + /// A bare status as a response, so a mapping can return 404 where it needs nothing else. + /// The HTTP status. + public static implicit operator FaultResponse(int statusCode) => Status(statusCode); +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs index a05f9ba..1ec3665 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs @@ -1,55 +1,100 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + namespace CodoMetis.TypeKit.AspNetCore; -/// The status a host answers each fault of one type with, and every status the type can give. -internal sealed class FaultResponses where TFault : struct, Enum +/// What every AddFaultResponses overload does, once it has the fault type's members and a response for each. +internal static class FaultRegistration { - private readonly Dictionary _statuses; + public static IServiceCollection Add(IServiceCollection services, FaultType type, Func responseOf, string parameter) + where TFault : notnull + { + // Two mappings for one fault type: whichever was registered last would answer, silently. + if (services.Any(descriptor => descriptor.ServiceType == typeof(FaultResponses))) + throw new InvalidOperationException( + $"The fault type {type.TypeName} is mapped already. Map it once, with a status for each member."); - private FaultResponses(Dictionary statuses) + services.AddSingleton(FaultResponses.Map(type, responseOf, parameter)); + services.TryAddEnumerable(ServiceDescriptor.Transient()); + services.AddProblemDetails(); + + return services; + } +} + +/// How a host answers each fault of one type, and every status the type can give. +internal sealed class FaultResponses where TFault : notnull +{ + private readonly Dictionary _answers; + + private FaultResponses(FaultType type, Dictionary answers) { - _statuses = statuses; - Statuses = [.. statuses.Values.Distinct().Order()]; + Type = type; + _answers = answers; + Statuses = [.. answers.Values.Select(answer => answer.Status).Distinct().Order()]; } - /// Every status a member is mapped to, ascending: what the document lists. + public FaultType Type { get; } + + /// Every status a member is mapped to, ascending: what the document lists where no endpoint changes one. public IReadOnlyList Statuses { get; } /// /// Maps every member up front, so a mapping that throws for one, or gives one a status that is no /// error, fails where it is registered and not on the request that first reports that fault. /// - public static FaultResponses Map(Func statusOf) + /// The fault type's members. + /// The response of each member. + /// The caller's parameter, which a refusal names: the mapping as the host wrote it. + public static FaultResponses Map(FaultType type, Func responseOf, string parameter) { - var statuses = new Dictionary(); - foreach (var fault in Enum.GetValues()) + var answers = new Dictionary(); + foreach (var fault in type.Members) { - var status = statusOf(fault); - if (status is < 400 or > 599) + var response = responseOf(fault) + ?? throw new ArgumentException($"{type.QualifiedNameOf(fault)} is mapped to no response." + FaultRefusals.Mapping, parameter); + + if (response.StatusCode is < 400 or > 599) throw new ArgumentException( - $"{typeof(TFault).Name}.{fault} is mapped to {status}, which is not an error status (400 to 599). " + + $"{type.QualifiedNameOf(fault)} is mapped to {response.StatusCode}, which is not an error status (400 to 599). " + "A fault is answered as problem details; a success is the value of the Result." + FaultRefusals.Mapping, - nameof(statusOf)); + parameter); - statuses[fault] = status; + answers[fault] = new Answer(response.StatusCode, response.Type, response.Title, type.NameOf(fault), type.QualifiedNameOf(fault), FaultType.DescriptionOf(fault)); } - return new FaultResponses(statuses); + return new FaultResponses(type, answers); } /// - /// The status of . A value no member names, which only a cast makes, has - /// none, and is refused rather than answered with a status the document does not list. + /// How is answered. A value no member names, which only a cast makes, has + /// no answer, and is refused rather than answered with a status the document does not list. /// - public int StatusOf(TFault fault) => - _statuses.TryGetValue(fault, out var status) - ? status + public Answer Of(TFault fault) => + _answers.TryGetValue(fault, out var answer) + ? answer : throw new InvalidOperationException( - $"{typeof(TFault).Name} has no member with the value {fault:D}, " + + $"{Type.TypeName} has no member {(fault is Enum ? $"with the value {fault:D}" : "that is this instance")}, " + "so no status is mapped for it." + FaultRefusals.Mapping); + /// Every status an endpoint gives, with the statuses its metadata sets for some members in place of the mapped ones. + public IReadOnlyList StatusesOf(IReadOnlyList endpointMetadata) => + [.. Type.Members.Select(fault => FaultStatuses.Override(endpointMetadata, fault, Type) ?? _answers[fault].Status).Distinct().Order()]; + + /// The problem types of the members an endpoint answers with , for that response's description. + public IReadOnlyList TypesOf(int status, IReadOnlyList endpointMetadata) => + [.. Type.Members.Where(fault => (FaultStatuses.Override(endpointMetadata, fault, Type) ?? _answers[fault].Status) == status) + .Select(fault => _answers[fault].Type) + .OfType() + .Distinct()]; + public static InvalidOperationException Unmapped() => new($"No HTTP status is mapped for the fault type {typeof(TFault).Name}. " + $"Map each of its members with services.AddFaultResponses<{typeof(TFault).Name}>(fault => …)." + FaultRefusals.Mapping); + + /// The mapped answer for one member: what the problem says, before an endpoint changes its status. + internal sealed record Answer(int Status, Uri? Type, string? Title, string Name, string QualifiedName, string? Description); } /// @@ -58,20 +103,46 @@ public static InvalidOperationException Unmapped() => /// internal abstract class FaultResponsesMetadata { + public abstract Type FaultType { get; } + public abstract string FaultName { get; } - /// The statuses the host mapped the fault type to, or where it mapped none. - public abstract IReadOnlyList? Statuses(IServiceProvider services); + /// The statuses the endpoint answers the fault type with, or where the host mapped none. + public abstract IReadOnlyList? Statuses(IServiceProvider services, IReadOnlyList endpointMetadata); + + /// The problem types listed for . + public abstract IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata); + + /// What is wrong with the endpoint's status overrides for this fault type: a name no member has. + public abstract IEnumerable Problems(IServiceProvider services, IReadOnlyList endpointMetadata); } -internal sealed class FaultResponsesMetadata : FaultResponsesMetadata where TFault : struct, Enum +internal sealed class FaultResponsesMetadata : FaultResponsesMetadata where TFault : notnull { public static readonly FaultResponsesMetadata Instance = new(); private FaultResponsesMetadata() { } + public override Type FaultType => typeof(TFault); + public override string FaultName => typeof(TFault).Name; - public override IReadOnlyList? Statuses(IServiceProvider services) => - (services.GetService(typeof(FaultResponses)) as FaultResponses)?.Statuses; + public override IReadOnlyList? Statuses(IServiceProvider services, IReadOnlyList endpointMetadata) => + Responses(services)?.StatusesOf(endpointMetadata); + + public override IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata) => + Responses(services)?.TypesOf(status, endpointMetadata) ?? []; + + public override IEnumerable Problems(IServiceProvider services, IReadOnlyList endpointMetadata) + { + if (Responses(services) is not { } responses) yield break; + + foreach (var status in endpointMetadata.OfType>()) + { + if (status.Problem(responses.Type) is { } problem) yield return problem; + } + } + + private static FaultResponses? Responses(IServiceProvider services) => + services.GetService(typeof(FaultResponses)) as FaultResponses; } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs index da999e8..1c277f2 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs @@ -10,8 +10,9 @@ namespace CodoMetis.TypeKit.AspNetCore; /// -/// Adds a problem-details response for every status an endpoint's fault type is mapped to, as -/// ProducesProblem(status) would. +/// Adds a problem-details response for every status an endpoint answers its fault type with, as +/// ProducesProblem(status) would: the mapped ones, with the endpoint's WithFaultStatus in +/// place of the mapped status for the members it names. /// /// /// The mapping is read here, from the application's services, and not in the endpoint's metadata, @@ -19,16 +20,20 @@ namespace CodoMetis.TypeKit.AspNetCore; /// components when operation transformers run, so the problem schema is added as the component the /// host's names, and referred to (finding 6). A /// response the operation already has is kept, which also makes a second AddTypeKit() a no-op. +/// A response's description is the status's reason phrase, followed by the problem types of the +/// members answered with it where the mapping gives any; fault stays a string on the shared +/// problem schema, since an enum of every fault type would change every operation when one is added. /// internal sealed class FaultResponsesOperationTransformer : IOpenApiOperationTransformer { public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { IOpenApiSchema? problem = null; + IReadOnlyList endpointMetadata = [.. context.Description.ActionDescriptor.EndpointMetadata]; - foreach (var metadata in context.Description.ActionDescriptor.EndpointMetadata.OfType().Distinct()) + foreach (var metadata in endpointMetadata.OfType().Distinct()) { - var statuses = metadata.Statuses(context.ApplicationServices) + var statuses = metadata.Statuses(context.ApplicationServices, endpointMetadata) ?? throw new InvalidOperationException( $"No HTTP status is mapped for the fault type {metadata.FaultName}. " + $"Map each of its members with services.AddFaultResponses<{metadata.FaultName}>(fault => …)." + FaultRefusals.Mapping); @@ -40,9 +45,12 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra if (operation.Responses.ContainsKey(key)) continue; problem ??= await ProblemSchemaAsync(context, cancellationToken); + var types = metadata.Types(context.ApplicationServices, status, endpointMetadata); operation.Responses[key] = new OpenApiResponse { - Description = ReasonPhrases.GetReasonPhrase(status), + Description = types.Count == 0 + ? ReasonPhrases.GetReasonPhrase(status) + : $"{ReasonPhrases.GetReasonPhrase(status)}. Problem types: {string.Join(", ", types)}", Content = new Dictionary { ["application/problem+json"] = new() { Schema = problem } }, }; } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs index 17785f9..e994908 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace CodoMetis.TypeKit.AspNetCore; /// -/// Refuses to start a host with an endpoint whose fault type has no mapping, naming each endpoint. +/// Refuses to start a host with an endpoint whose fault type has no mapping, or whose +/// WithFaultStatus names a fault type it does not answer with or a member its type does not +/// have, naming each endpoint. /// /// /// A startup filter, because the endpoints are known only once the pipeline is built: a hosted service @@ -25,16 +28,28 @@ public Action Configure(Action next) = var unmapped = endpoints.Endpoints .SelectMany(endpoint => endpoint.Metadata.OfType().Select(metadata => (Endpoint: endpoint, Metadata: metadata))) - .Where(pair => pair.Metadata.Statuses(services) is null) - .GroupBy(pair => pair.Metadata.FaultName, pair => pair.Endpoint.DisplayName ?? pair.Endpoint.ToString()) - .ToList(); + .Where(pair => pair.Metadata.Statuses(services, pair.Endpoint.Metadata) is null) + .GroupBy(pair => pair.Metadata.FaultName, pair => Name(pair.Endpoint)) + .Select(fault => + $"No HTTP status is mapped for the fault type {fault.Key}, which these endpoints answer with: " + + $"{string.Join(", ", fault.Distinct())}. Map each of its members with services.AddFaultResponses<{fault.Key}>(fault => …)."); - if (unmapped.Count == 0) return; + // A status for a fault type the endpoint never answers with is stale, or set on the wrong endpoint. + var stray = endpoints.Endpoints + .SelectMany(endpoint => endpoint.Metadata.OfType() + .Where(status => !endpoint.Metadata.OfType().Any(answered => answered.FaultType == status.FaultType)) + .Select(status => $"{status.Describe()} is set on {Name(endpoint)}, which answers with no {status.FaultType.Name}.")); - throw new InvalidOperationException( - string.Join(" ", unmapped.Select(fault => - $"No HTTP status is mapped for the fault type {fault.Key}, which these endpoints answer with: " + - $"{string.Join(", ", fault.Distinct())}. Map each of its members with services.AddFaultResponses<{fault.Key}>(fault => …).")) + - FaultRefusals.Mapping); + var nameless = endpoints.Endpoints + .SelectMany(endpoint => endpoint.Metadata.OfType().Distinct() + .SelectMany(metadata => metadata.Problems(services, endpoint.Metadata)) + .Select(problem => $"{problem}, on {Name(endpoint)}.")); + + var refusals = unmapped.Concat(stray).Concat(nameless).ToList(); + if (refusals.Count == 0) return; + + throw new InvalidOperationException(string.Join(" ", refusals) + FaultRefusals.Mapping); }; + + private static string Name(Endpoint endpoint) => endpoint.DisplayName ?? endpoint.ToString() ?? "an endpoint"; } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs new file mode 100644 index 0000000..2c3cd7d --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs @@ -0,0 +1,138 @@ +using CodoMetis.TypeKit.AspNetCore; +using Microsoft.AspNetCore.Http; + +namespace CodoMetis.TypeKit.AspNetCore +{ + /// A status an endpoint answers some fault with in place of the host's: what the startup check reads without knowing the type. + internal interface IFaultStatus + { + Type FaultType { get; } + + /// How the metadata reads in a message: WithFaultStatus(OrderFault.NameTaken, 422). + string Describe(); + } + + /// The typed form, which the response, the document and the startup check read. + internal interface IFaultStatus : IFaultStatus where TFault : notnull + { + int Status { get; } + + bool Matches(TFault fault, FaultType type); + + /// Why the metadata names no member, or where it names one. + string? Problem(FaultType type); + } + + internal sealed class FaultStatusMetadata(TFault fault, int status) : IFaultStatus where TFault : notnull + { + public Type FaultType => typeof(TFault); + + public int Status => status; + + public bool Matches(TFault candidate, FaultType type) => EqualityComparer.Default.Equals(candidate, fault); + + public string? Problem(FaultType type) => + type.Members.Contains(fault) ? null : $"{Describe()} names a value no member of {type.TypeName} has"; + + public string Describe() => $"WithFaultStatus({typeof(TFault).Name}.{fault}, {status})"; + } + + /// The ordered override: the last status the endpoint's metadata sets for a member, as ASP.NET reads metadata. + internal static class FaultStatuses + { + public static int? Override(IReadOnlyList metadata, TFault fault, FaultType type) where TFault : notnull + { + int? status = null; + foreach (var item in metadata) + { + if (item is IFaultStatus candidate && candidate.Matches(fault, type)) status = candidate.Status; + } + + return status; + } + + public static void ThrowIfNoErrorStatus(int status, string parameter) + { + if (status is < 400 or > 599) + throw new ArgumentOutOfRangeException(parameter, status, $"{status} is not an error status (400 to 599): a fault is answered as problem details." + FaultRefusals.Mapping); + } + } + + /// + /// An MVC action's status for one fault, in place of the host's: [FaultStatus<OrderFault>(nameof(OrderFault.NameTaken), 422)]. + /// A minimal API endpoint says it with .WithFaultStatus(OrderFault.NameTaken, 422). + /// + /// + /// The member is named, since an attribute takes only constants, and nameof keeps the name + /// in step with the member. A name no member has stops the host from starting, as an endpoint + /// whose fault type is not mapped does. The response, the operation's document entry and the + /// startup check all read it. + /// + /// The fault type, an enum or a closed set. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] + public sealed class FaultStatusAttribute : Attribute, IFaultStatus where TFault : notnull + { + /// Answers the member named with on this action. + /// The member's name, as nameof(OrderFault.NameTaken) gives it. + /// The HTTP status, 400 to 599. + /// is not an error status. + public FaultStatusAttribute(string fault, int status) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fault); + FaultStatuses.ThrowIfNoErrorStatus(status, nameof(status)); + + Fault = fault; + Status = status; + } + + /// The member's name. + public string Fault { get; } + + /// The HTTP status. + public int Status { get; } + + Type IFaultStatus.FaultType => typeof(TFault); + + bool IFaultStatus.Matches(TFault fault, FaultType type) => string.Equals(type.NameOf(fault), Fault, StringComparison.Ordinal); + + string? IFaultStatus.Problem(FaultType type) => + type.TryFind(Fault, out _) ? null : $"{((IFaultStatus)this).Describe()} names no member of {type.TypeName}"; + + string IFaultStatus.Describe() => $"[FaultStatus<{typeof(TFault).Name}>(\"{Fault}\", {Status})]"; + } +} + +// Beside WithMetadata and the other endpoint conventions. +namespace Microsoft.AspNetCore.Builder +{ + /// A status for one fault on one endpoint, in place of the host's. + public static class TypeKitFaultStatusEndpointConventionBuilderExtensions + { + /// + /// Answers with on this endpoint, in place of the + /// status the host maps it to: the same fault can be a 404 on one route and a 409 on another. + /// + /// + /// The response, the operation's document entry and the startup check all read it. A status for + /// a fault type the endpoint does not answer with stops the host from starting, so a stale one is + /// found before it misleads. + /// + /// The endpoint, or a group or convention of endpoints. + /// The fault, an enum member or a closed set's member. + /// The HTTP status, 400 to 599. + /// The builder. + /// The fault type. + /// The same builder. + /// is not an error status. + public static TBuilder WithFaultStatus(this TBuilder builder, TFault fault, int status) + where TBuilder : IEndpointConventionBuilder + where TFault : notnull + { + ArgumentNullException.ThrowIfNull(builder); + if (fault is null) throw new ArgumentNullException(nameof(fault)); + FaultStatuses.ThrowIfNoErrorStatus(status, nameof(status)); + + return builder.WithMetadata(new FaultStatusMetadata(fault, status)); + } + } +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultType.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultType.cs new file mode 100644 index 0000000..f89835f --- /dev/null +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultType.cs @@ -0,0 +1,70 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace CodoMetis.TypeKit.AspNetCore; + +/// +/// What the satellite knows of a fault type, an enum or a closed set: its members, and each member's +/// name, qualified name, kind and description. The mapping, the startup check, the document and the +/// response read a fault type through this alone, so an enum and a closed set take the same path. +/// +/// +/// Made once per type, by the registration overload whose constraint says which it is, so nothing +/// reflects: an enum's members come from , a set's from its +/// All. A member's kind and description are read through , which a fault +/// set's members implement and an enum's never do. +/// +internal sealed class FaultType where TFault : notnull +{ + private readonly Func _name; + private readonly Func _qualifiedName; + + internal FaultType(string typeName, IReadOnlyList members, Func name, Func qualifiedName) + { + TypeName = typeName; + Members = members; + _name = name; + _qualifiedName = qualifiedName; + } + + /// The fault type's name, as every message names it. + public string TypeName { get; } + + /// Every member, in declaration order. + public IReadOnlyList Members { get; } + + public string NameOf(TFault fault) => _name(fault); + + public string QualifiedNameOf(TFault fault) => _qualifiedName(fault); + + /// The member's kind, where it is a fault set's member; an enum's has none. + public static FaultKind? KindOf(TFault fault) => fault is IFault member ? member.Kind : null; + + /// The member's description, where it is a fault set's member that declares one. + public static string? DescriptionOf(TFault fault) => fault is IFault member ? member.Description : null; + + /// The member of that name, compared ordinally. + public bool TryFind(string name, out TFault fault) + { + foreach (var member in Members) + { + if (string.Equals(NameOf(member), name, StringComparison.Ordinal)) + { + fault = member; + return true; + } + } + + fault = default!; + return false; + } +} + +/// Makes the of an enum or a closed set, each under the constraint that says which. +internal static class FaultTypes +{ + public static FaultType Enum() where TEnum : struct, Enum => + new(typeof(TEnum).Name, System.Enum.GetValues(), static fault => fault.ToString(), static fault => $"{typeof(TEnum).Name}.{fault}"); + + public static FaultType Set() where TSet : class, IClosedSet => + new(typeof(TSet).Name, TSet.All, static fault => fault.Name, static fault => fault.QualifiedName); +} diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 061b15b..4d7d9d1 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -138,8 +138,8 @@ app.MapDelete("/orders/{id}", (OrderId id) => orders.Cancel(id).ToHttpResult()); { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10", "title": "Conflict", "status": 409, "fault": "AlreadyShipped", "traceId": "…" } ``` -- **The domain declares faults, the host their statuses.** The fault type is an enum the domain owns; - only the host references ASP.NET. A problem names the fault, never anything of the request. +- **The domain declares faults, the host their statuses.** The fault type is an enum or a closed set + the domain owns; only the host references ASP.NET. A problem names the fault, never anything of the request. - **The document says it too.** The endpoint's return type is ASP.NET's own `Results, FaultProblem>`, so the operation describes the value, and `AddTypeKit()` adds a problem response for every status the fault type is mapped to, as @@ -157,7 +157,7 @@ app.MapDelete("/orders/{id}", (OrderId id) => orders.Cancel(id).ToHttpResult()); ### Mapping faults to statuses -`AddFaultResponses` maps every member of the enum when it is called, so a mapping that throws for a +`AddFaultResponses` maps every member of the fault type when it is called, so a mapping that throws for a member, or gives one a status that is no error (outside 400 to 599), fails there, at startup. That is what the discard arm above relies on: a member the `switch` misses throws while the host starts, not when the fault first occurs. Without the arm, C# warns (CS8524) that a cast could produce a value no @@ -169,6 +169,127 @@ a host that never calls it refuses the first fault an endpoint reports, and its with the same message. A value no member names, which only a cast makes, is refused rather than answered with a status the document does not list. +### A response per fault + +A mapping can say more than a status: RFC 9457 makes the problem's `type` its primary identifier, +which a client matches on, and `title` its summary. Both name the problem type and nothing of the +request; without them, ASP.NET writes the status's own. + +```csharp +builder.Services.AddFaultResponses(fault => fault switch +{ + PaymentFault.Declined => FaultResponse.Status(StatusCodes.Status402PaymentRequired).WithType(new Uri("https://example.com/problems/declined")), + PaymentFault.Duplicate => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), +}); +``` + +A bare status converts to a `FaultResponse`, so the arms that need nothing else stay a number. Every +member is still mapped and checked when this is called. The document's problem response for a +status lists the problem types answered with it in its description: +`Payment Required. Problem types: https://example.com/problems/declined`. + +### A closed set as the fault type + +A closed set whose members are faults declares `IFaultSet`: each member carries a `FaultKind`, +nothing HTTP-specific, and optionally a sentence a person reads. + +```csharp +public sealed partial class AccountFault : IFaultSet +{ + public static readonly AccountFault NotFound = new(FaultKind.NotFound, "The account does not exist."); + public static readonly AccountFault EmailTaken = new(FaultKind.Conflict, "Another account uses that email address."); + public static readonly AccountFault Locked = new(FaultKind.Forbidden, null); // a refusal that must not explain itself + + private AccountFault(FaultKind kind, string? description) { Kind = kind; Description = description; } + + public FaultKind Kind { get; } + public string? Description { get; } +} +``` + +```csharp +builder.Services.AddFaultResponses(); // every member by its kind, its description as the detail +``` + +A kind gives the status: `NotFound` 404, `Conflict` 409, `Invalid` 422, `Forbidden` 403 and +`Unavailable` 503. A member whose kind is `default`, which is no kind, fails this call. The +description becomes the problem's `detail`, so a client can show it; a member without one has no +`detail`. To change some members, map them all, with the rest as their kind says: +`fault => fault == AccountFault.Locked ? 423 : FaultResponse.For(fault.Kind)`. Any closed set can be a +fault type with a mapping of its own, `AddFaultResponses(fault => …)`, and `ToHttpResult()` +takes a result over either. + +### A sentence per occurrence + +What varies per occurrence travels in a `Fault` (CodoMetis.TypeKit): the fault, and a sentence +about this one. The response, the mapping and the document stay keyed on the fault type. + +```csharp +Result> taken = Result.Error(Fault.Of(AccountFault.EmailTaken, "The address has been in use since 3 May.")); +``` + +```csharp +app.MapPut("/accounts/{id:int}/email", (int id, NewAccount body) => accounts.Rename(id, body.Email).ToHttpResult()); +``` + +The detail is the problem's `detail`, in place of the member's description. It is the application's +sentence, written as it is: a fault's name never quotes the request, and whether a detail does is the +application's to decide. + +### A status per endpoint + +The same fault can mean 404 on one route and 409 on another. An endpoint changes the status of one +fault for itself, in its response and in its document entry: + +```csharp +app.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()) + .WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); +``` + +An MVC action says it with an attribute, naming the member, since an attribute takes only constants: + +```csharp +[HttpPost("/mvc/accounts")] +[FaultStatus(nameof(AccountFault.EmailTaken), StatusCodes.Status422UnprocessableEntity)] +public Results, FaultProblem> Open(NewAccount body) => accounts.Open(body).ToHttpResult(); +``` + +A status outside 400 to 599 is refused where it is set. A status for a fault type the endpoint does +not answer with, or an attribute naming no member, stops the host from starting, naming the endpoint. + +### The success the endpoint chooses + +A success is `200 OK` with its value unless the endpoint says otherwise, with any of ASP.NET's typed +results, so the document still describes it: + +```csharp +app.MapPost("/v2/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult(account => TypedResults.Created($"/accounts/{account.Id}", account))); +``` + +The operation lists `201` with the account as its body, beside the problem responses, and the +response carries the `Location` header. A result of the application's own, one that sets an ETag, +takes the same place. + +### What the host adds to a problem + +While `ToHttpResult()` writes a problem, the request carries an `IFaultFeature`: the fault type, the +fault, the name the problem gives it and its detail. A `CustomizeProblemDetails` of the host's reads +it to add what the package does not name, and finds it on no other problem the request writes. + +```csharp +builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => +{ + if (context.HttpContext.Features.Get() is { } fault) + context.ProblemDetails.Extensions["code"] = $"ACC-{fault.Name}"; // the host's own extension, for this fault only +}); +builder.Services.Configure(options => options.QualifiedFaultNames = true); // "fault": "AccountFault.EmailTaken" +``` + +`QualifiedFaultNames` writes `fault` with its type, so two fault types that share a member name stay +apart in one client; the default is the bare name. `fault` stays a string on the document's shared +problem schema, since an enum of every fault type would change every operation when one is added. + ## NodaTime ASP.NET cannot see through NodaTime's JSON converters, so once they are on the host's JSON options diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs index efe527f..6d9600f 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitFaultResponsesServiceCollectionExtensions.cs @@ -1,11 +1,11 @@ using CodoMetis.TypeKit.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection.Extensions; +using CodoMetis.TypeKit.ClosedSets; // Beside AddProblemDetails, in a namespace every web project imports implicitly. namespace Microsoft.Extensions.DependencyInjection; -/// Maps the faults of a Result to the HTTP statuses a host answers them with. +/// Maps the faults of a Result, an enum's members, to the HTTP responses a host answers them with. +/// A closed set's members take the same calls from . public static class TypeKitFaultResponsesServiceCollectionExtensions { /// @@ -36,15 +36,82 @@ public static IServiceCollection AddFaultResponses(this IServiceCollecti ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(statusOf); - // Two mappings for one fault type: whichever was registered last would answer, silently. - if (services.Any(descriptor => descriptor.ServiceType == typeof(FaultResponses))) - throw new InvalidOperationException( - $"The fault type {typeof(TFault).Name} is mapped already. Map it once, with a status for each member."); + return FaultRegistration.Add(services, FaultTypes.Enum(), fault => FaultResponse.Status(statusOf(fault)), nameof(statusOf)); + } + + /// + /// Answers every fault of with the response + /// gives it: a status, and optionally the problem's type and title. + /// + /// + /// As the overload that takes a status: every member is mapped here, the document lists each + /// status, and the host does not start with an endpoint whose fault type is not mapped. A bare + /// status converts to a , so a mapping returns 404 for one member + /// and FaultResponse.Status(409).WithType(uri) for another. + /// + /// The fault type, an enum. + /// The host's services. + /// The response of each fault, typically a switch expression. + /// The same services. + /// is mapped already. + /// A member is mapped to a status that is not an error. + public static IServiceCollection AddFaultResponses(this IServiceCollection services, Func responseOf) + where TFault : struct, Enum + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(responseOf); + + return FaultRegistration.Add(services, FaultTypes.Enum(), responseOf, nameof(responseOf)); + } +} + +/// Maps the faults of a Result, a closed set's members, to the HTTP responses a host answers them with. +/// Separate from the enum's overloads, which take the same parameters, since C# tells the two apart by their constraints alone. +public static class TypeKitClosedSetFaultResponsesServiceCollectionExtensions +{ + /// + /// Answers every member of the fault set by its , + /// with no mapping written: NotFound 404, Conflict 409, Invalid 422, + /// Forbidden 403 and Unavailable 503, and its description as the problem's detail. + /// + /// + /// A member whose kind is no , a default one, fails this call. To change + /// some members, map them all: fault => fault == OrderFault.Gone ? 410 : FaultResponse.For(fault.Kind). + /// + /// The fault set. + /// The host's services. + /// The same services. + /// is mapped already. + /// A member's kind is no . + public static IServiceCollection AddFaultResponses(this IServiceCollection services) + where TFault : class, IFaultSet + { + ArgumentNullException.ThrowIfNull(services); - services.AddSingleton(FaultResponses.Map(statusOf)); - services.TryAddEnumerable(ServiceDescriptor.Transient()); - services.AddProblemDetails(); + return FaultRegistration.Add(services, FaultTypes.Set(), static fault => + { + try { return FaultResponse.For(fault.Kind); } + catch (ArgumentOutOfRangeException refused) { throw new ArgumentException($"{fault.QualifiedName}: {refused.Message}", nameof(TFault), refused); } + }, nameof(TFault)); + } + + /// + /// Answers every member of the closed set with the response + /// gives it, as the enum's overload does. + /// + /// Every member is mapped here; a bare status converts to a . + /// The fault type, a closed set. + /// The host's services. + /// The response of each member. + /// The same services. + /// is mapped already. + /// A member is mapped to a status that is not an error. + public static IServiceCollection AddFaultResponses(this IServiceCollection services, Func responseOf) + where TFault : class, IClosedSet + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(responseOf); - return services; + return FaultRegistration.Add(services, FaultTypes.Set(), responseOf, nameof(responseOf)); } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs index f76d621..bd963ca 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs @@ -1,19 +1,32 @@ +using System.Diagnostics.CodeAnalysis; using CodoMetis.TypeKit; using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ClosedSets; using Microsoft.AspNetCore.Http.HttpResults; // Beside TypedResults, in a namespace every web project imports implicitly. namespace Microsoft.AspNetCore.Http; -/// Turns a Result into the HTTP response an endpoint returns. +/// Turns a Result whose fault is an enum, or a , into the HTTP response an endpoint returns. /// +/// /// Matching a result to a response is not serializing it: the success's value is written as the /// endpoint's body, and the Result itself still refuses System.Text.Json. A handler that /// returns the Result instead would document a Result, and under Native AOT need one in /// the host's JSON context (spikes/ResultHttp, findings 9 and 10). +/// +/// +/// A result whose fault is a closed set takes the same calls from +/// : C# tells the two apart by their constraints, so +/// they live in two classes and resolve without type arguments. +/// /// public static class TypeKitHttpResultExtensions { + // What ASP.NET's Results asks of its first type argument, carried to the + // success type the endpoint chooses, so the trimmer keeps the members it describes the response by. + internal const DynamicallyAccessedMemberTypes SuccessMembers = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods; + /// /// A success as 200 OK with its value, a fault as problem details with the status /// AddFaultResponses mapped it to. @@ -43,4 +56,130 @@ public static Results> ToHttpResult(this result.Match>>( static () => TypedResults.NoContent(), static fault => new FaultProblem(fault)); + + /// + /// A success as the response makes of its value, such as + /// TypedResults.Created(uri, order); a fault as problem details. + /// + /// The success is ASP.NET's own typed result, so the operation describes its status and body, a 201 with its value. + /// The outcome of the operation the endpoint ran. + /// Makes the response for the value. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where T : notnull + where TError : struct, Enum + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(value => success(value), static fault => new FaultProblem(fault)); + } + + /// A success as the response makes, such as TypedResults.Accepted(uri); a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// Makes the response for a success. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where TError : struct, Enum + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(() => success(), static fault => new FaultProblem(fault)); + } + + /// + /// A success as 200 OK with its value; a fault as problem details for its reason, with its + /// detail as the problem's detail. The mapping and the document are the reason's type's. + /// + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results, FaultProblem> ToHttpResult(this Result> result) + where T : notnull + where TFault : notnull => + result.Match, FaultProblem>>( + static value => TypedResults.Ok(value), + static fault => new FaultProblem(fault.Reason, fault.Detail)); + + /// A success as 204 No Content; a fault as problem details for its reason, with its detail. + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results> ToHttpResult(this Result> result) + where TFault : notnull => + result.Match>>( + static () => TypedResults.NoContent(), + static fault => new FaultProblem(fault.Reason, fault.Detail)); + + /// A success as the response makes of its value; a fault as problem details for its reason, with its detail. + /// The outcome of the operation the endpoint ran. + /// Makes the response for the value. + /// The response. + public static Results> ToHttpResult(this Result> result, Func success) + where T : notnull + where TFault : notnull + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(value => success(value), static fault => new FaultProblem(fault.Reason, fault.Detail)); + } + + /// A success as the response makes; a fault as problem details for its reason, with its detail. + /// The outcome of the operation the endpoint ran. + /// Makes the response for a success. + /// The response. + public static Results> ToHttpResult(this Result> result, Func success) + where TFault : notnull + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(() => success(), static fault => new FaultProblem(fault.Reason, fault.Detail)); + } +} + +/// Turns a Result whose fault is a closed set into the HTTP response an endpoint returns, as does for an enum. +public static class TypeKitClosedSetHttpResultExtensions +{ + private const DynamicallyAccessedMemberTypes SuccessMembers = TypeKitHttpResultExtensions.SuccessMembers; + + /// A success as 200 OK with its value, a fault as problem details with the status the host answers it with. + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results, FaultProblem> ToHttpResult(this Result result) + where T : notnull + where TError : class, IClosedSet => + result.Match, FaultProblem>>( + static value => TypedResults.Ok(value), + static fault => new FaultProblem(fault)); + + /// A success as 204 No Content, a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// The response. + public static Results> ToHttpResult(this Result result) + where TError : class, IClosedSet => + result.Match>>( + static () => TypedResults.NoContent(), + static fault => new FaultProblem(fault)); + + /// A success as the response makes of its value; a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// Makes the response for the value. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where T : notnull + where TError : class, IClosedSet + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(value => success(value), static fault => new FaultProblem(fault)); + } + + /// A success as the response makes; a fault as problem details. + /// The outcome of the operation the endpoint ran. + /// Makes the response for a success. + /// The response. + public static Results> ToHttpResult(this Result result, Func success) + where TError : class, IClosedSet + where TSuccess : IResult + { + ArgumentNullException.ThrowIfNull(success); + return result.Match>>(() => success(), static fault => new FaultProblem(fault)); + } } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index f4887a0..b375166 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -234,7 +234,9 @@ reads a string through `TypeDescriptor` bind a set. The binder's source generato converter ([dotnet/runtime#83599](https://github.com/dotnet/runtime/issues/83599)): under it, as under Native AOT, bind the name as a `string` and `Parse` it. The EF Core satellite maps a closed set to a text column of the member's name, and the ASP.NET Core -satellite describes it as a string enum of the names, with nothing registered per set. +satellite describes it as a string enum of the names, with nothing registered per set. A set whose +members are faults declares `IFaultSet`, a `FaultKind` and an optional description per member, +and the ASP.NET Core satellite answers it by kind. Each member is initialized with `new` where it is declared (CMTK1019), and the constructors are private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated diff --git a/src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs new file mode 100644 index 0000000..8d79f5b --- /dev/null +++ b/src/CodoMetis.TypeKit/ClosedSets/IFaultSet.cs @@ -0,0 +1,73 @@ +namespace CodoMetis.TypeKit.ClosedSets; + +/// +/// What kind of refusal a fault is, as a client acts on it: nothing HTTP-specific, so a domain +/// declares it without referencing ASP.NET, and a host derives its status from it. +/// +/// +/// The values start at 1: a default kind is no kind, and a host refuses it where the faults are +/// mapped rather than answer it as a . +/// +public enum FaultKind +{ + /// What the request names does not exist. A host answers 404 Not Found. + NotFound = 1, + + /// + /// The request conflicts with the current state: a name taken, a stale version, a step the state + /// forbids. A host answers 409 Conflict. + /// + Conflict, + + /// The request is well formed and a rule refuses it. A host answers 422 Unprocessable Content. + Invalid, + + /// The caller may not do this. A host answers 403 Forbidden. + Forbidden, + + /// Something the operation needs is unavailable, and the same request may succeed later. A host answers 503 Service Unavailable. + Unavailable, +} + +/// What a member of a fault set says of itself: the kind of refusal, and a sentence a person reads. +/// +/// Implemented through . Non-generic, so code that holds a fault of a +/// type it does not know, such as the ASP.NET Core satellite, reads it with an interface check and no +/// reflection. +/// +public interface IFault +{ + /// What kind of refusal this is, from which a host derives its status. + FaultKind Kind { get; } + + /// + /// The fault as a person reads it, a sentence a client can show, or for a + /// refusal that must not explain itself, a credential's or a secret's. + /// + /// + /// The same for every occurrence: what varies per occurrence travels as + /// . Unless a set declares it, it is . + /// + string? Description => null; +} + +/// +/// Declares a closed set whose members are faults, each with the a host derives +/// its status from and an optional : +/// public static readonly OrderFault NotFound = new(FaultKind.NotFound, "The order does not exist."); +/// +/// +/// +/// A closed set in every other respect (): the generators add its name, +/// All, Match, parsing and JSON. The set declares , and +/// where its members carry one, through its private constructor, so a +/// member's kind is read where it is declared, without reflection. +/// +/// +/// In CodoMetis.TypeKit.AspNetCore, AddFaultResponses<TFault>() maps every member by its +/// kind, and the description is the problem's detail unless the occurrence carries one of its own. +/// A smart enum names a fault; data that varies per occurrence travels as . +/// +/// +/// The set itself. +public interface IFaultSet : IClosedSet, IFault where TSelf : class, IFaultSet; diff --git a/src/CodoMetis.TypeKit/Fault.cs b/src/CodoMetis.TypeKit/Fault.cs new file mode 100644 index 0000000..06cbe52 --- /dev/null +++ b/src/CodoMetis.TypeKit/Fault.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace CodoMetis.TypeKit; + +/// +/// A fault and a sentence about this occurrence of it: Result<Order, Fault<OrderFault>>, +/// so what varies per occurrence travels beside a fault that is still one of a fixed set. +/// +/// +/// +/// The reason is an enum member or a closed-set member, which a host maps to a response, keyed on the +/// reason's type, so Fault<OrderFault> is answered as OrderFault is. The detail is +/// the application's sentence, written into the problem's detail as it is: unlike a fault's name, +/// it can say anything, so whether it quotes the request is the application's to decide. +/// +/// +/// A reason converts to a fault without a detail, and infers the type: +/// Result.Error(Fault.Of(OrderFault.NotAmendable, "The order shipped on 3 May.")). Not a wire type: +/// it is matched to a response, as the holding it is. +/// +/// +/// The reason's type: an enum or a closed set. +[JsonConverter(typeof(NotWireTypeJsonConverterFactory))] +[RequireCustomInitialization("Use Fault.Of(reason, detail), or the reason itself.")] +public readonly record struct Fault where TFault : notnull +{ + /// Creates the fault. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// is null. + /// is empty or white space: say for no detail. + public Fault(TFault reason, string? detail = null) + { + Reason = reason is null ? throw ThrowHelper.NullContent(nameof(reason)) : reason; + Detail = detail is not null && string.IsNullOrWhiteSpace(detail) + ? throw new ArgumentException("A fault's detail is a sentence, or null for none; it is never blank.", nameof(detail)) + : detail; + } + + /// Which fault. + public TFault Reason { get; } + + /// A sentence about this occurrence, or . + public string? Detail { get; } + + /// A reason as a fault without a detail. + /// Which fault. Never null. + public static implicit operator Fault(TFault reason) => new(reason); +} + +/// Creates a , inferring its type. +public static class Fault +{ + /// A fault with a sentence about this occurrence. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The reason's type, inferred. + /// The fault. + /// is null. + /// is empty or white space. + public static Fault Of(TFault reason, string? detail = null) where TFault : notnull => new(reason, detail); +} diff --git a/src/CodoMetis.TypeKit/NotWireTypeJsonConverterFactory.cs b/src/CodoMetis.TypeKit/NotWireTypeJsonConverterFactory.cs index b08b442..8a9ffac 100644 --- a/src/CodoMetis.TypeKit/NotWireTypeJsonConverterFactory.cs +++ b/src/CodoMetis.TypeKit/NotWireTypeJsonConverterFactory.cs @@ -6,8 +6,8 @@ namespace CodoMetis.TypeKit; /// /// The JSON converter behind , , -/// and the Option.None, Result.Success and Result.Error -/// markers. It refuses to read or write any of them, because none of them is a wire type. +/// , and the Option.None, +/// Result.Success and Result.Error markers. It refuses to read or write any of them, because none of them is a wire type. /// /// /// @@ -55,7 +55,7 @@ namespace CodoMetis.TypeKit; public sealed class NotWireTypeJsonConverterFactory : JsonConverterFactory { private static readonly Type[] RefusedDefinitions = - [typeof(Option<>), typeof(Result<>), typeof(Result<,>), typeof(Success<>), typeof(Error<>)]; + [typeof(Option<>), typeof(Result<>), typeof(Result<,>), typeof(Success<>), typeof(Error<>), typeof(Fault<>)]; /// public override bool CanConvert(Type typeToConvert) => IsRefused(typeToConvert); diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 295ecf2..b0f8ff7 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -194,6 +194,14 @@ null throws, as `Result.Success(null)` does. `Map`, `Bind` and the rest are unch that throws propagates. An exception is for the code that called, not for a client: `ToHttpResult()` does not accept a `Result`, and `MapError` turns the exception into a fault that it does. +**A fault with a sentence.** A fault is one of a fixed set, an enum's member or a closed set's, so +what varies per occurrence travels beside it: `Fault` holds the fault, its `Reason`, and an +optional `Detail`. `Fault.Of(OrderFault.NotAmendable, "The order shipped on 3 May.")` infers the type, +and a bare fault converts to one without a detail. CodoMetis.TypeKit.AspNetCore answers a +`Result>` as it answers the fault, with the detail as the problem's `detail`. A +closed set whose members are faults declares `IFaultSet`, a `FaultKind` and an optional +description per member, from which the host derives each status. + ## Not wire types The one wire type here is `Patch`, a merge patch's field (below). diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs new file mode 100644 index 0000000..ca8e808 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs @@ -0,0 +1,370 @@ +using System.Net; +using System.Reflection; +using System.Text.Json.Nodes; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// The fault responses a client can act on (spikes/FaultResponses): a response per member, a closed set +/// as a fault type answered by its kinds, a sentence per occurrence, a status per endpoint, the +/// endpoint's own success, and the fault on the request while its problem is written. Each claim of the +/// spike is a test here; decision 38's default response is held by . +/// +public sealed class FaultResponsePartsTests +{ + public enum OrderFault { NotFound, NameTaken } + + public sealed record Order(int Id, string Name); + + private static readonly Uri NameTakenType = new("https://example.com/problems/name-taken"); + + private static Result Rename(int id) => + id == 1 ? new Order(1, "renamed") : Result.Error(id == 2 ? OrderFault.NameTaken : OrderFault.NotFound); + + private static Result> RenameWithDetail(int id) => + id == 1 ? new Order(1, "renamed") : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.")); + + private static Result Find(int id) => id switch + { + 1 => new Order(1, "book"), + 2 => Result.Error(ProbeOrderFault.NameTaken), + 3 => Result.Error(ProbeOrderFault.Locked), + _ => Result.Error(ProbeOrderFault.NotFound), + }; + + private static void Responses(IServiceCollection services) + { + services.AddFaultResponses(fault => fault switch + { + OrderFault.NotFound => StatusCodes.Status404NotFound, + OrderFault.NameTaken => FaultResponse.Status(StatusCodes.Status409Conflict).WithType(NameTakenType).WithTitle("The name is taken"), + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + services.AddFaultResponses(); + } + + private static void Endpoints(WebApplication app) + { + app.MapPut("/orders/{id:int}", (int id) => Rename(id).ToHttpResult()); + app.MapPut("/strict/orders/{id:int}", (int id) => Rename(id).ToHttpResult()).WithFaultStatus(OrderFault.NameTaken, StatusCodes.Status422UnprocessableEntity); + app.MapPut("/detailed/orders/{id:int}", (int id) => RenameWithDetail(id).ToHttpResult()); + app.MapGet("/catalog/{id:int}", (int id) => Find(id).ToHttpResult()); + app.MapPost("/orders", () => Rename(1).ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order))); + app.MapGet("/elsewhere", () => TypedResults.Problem(statusCode: StatusCodes.Status418ImATeapot)); + } + + // 1: the FaultResponse overload checks every member where it is mapped, as the int one does. + [Theory] + [InlineData(200)] + [InlineData(302)] + [InlineData(600)] + public void A_response_per_member_is_checked_where_it_is_mapped(int status) + { + var refusal = Should.Throw(() => new ServiceCollection().AddFaultResponses( + fault => fault == OrderFault.NameTaken ? FaultResponse.Status(status).WithType(NameTakenType) : 404)); + + refusal.ParamName.ShouldBe("responseOf"); + refusal.Message.ShouldStartWith($"OrderFault.NameTaken is mapped to {status}, which is not an error status (400 to 599)."); + } + + /// A kind is required: a member whose kind is default has none, and the set is refused where it is mapped, naming the member. + [Fact] + public void A_fault_set_with_a_member_of_no_kind_is_refused_where_it_is_mapped() + { + var refusal = Should.Throw(() => new ServiceCollection().AddFaultResponses()); + + refusal.Message.ShouldStartWith("ProbeKindlessFault.Unset: 0 is no FaultKind"); + } + + // 2: a type URI and a title reach the body and the operation's problem response; nothing of the input does. + [Fact] + public async Task A_type_and_a_title_reach_the_problem_and_the_document() + { + await using var app = await StartAsync(); + + var body = await ProblemAsync(app, "/orders/2", HttpStatusCode.Conflict); + body["type"]!.GetValue().ShouldBe(NameTakenType.ToString()); + body["title"]!.GetValue().ShouldBe("The name is taken"); + body["fault"]!.GetValue().ShouldBe("NameTaken"); + + var conflict = (await DocumentAsync(app))["paths"]!["/orders/{id}"]!["put"]!["responses"]!["409"]!; + conflict["description"]!.GetValue().ShouldBe($"Conflict. Problem types: {NameTakenType}"); + } + + // 3: ToHttpResult over Result>, Result and a closed set's resolves without type arguments. + [Fact] + public void ToHttpResult_resolves_for_an_enum_a_fault_and_a_closed_set_without_type_arguments() + { + Rename(2).ToHttpResult().Result.ShouldBeOfType>().Fault.ShouldBe(OrderFault.NameTaken); + + var detailed = RenameWithDetail(2).ToHttpResult().Result.ShouldBeOfType>(); + detailed.Fault.ShouldBe(OrderFault.NameTaken); + detailed.Detail.ShouldBe("Order 7 is called that."); + + ReferenceEquals(Find(3).ToHttpResult().Result.ShouldBeOfType>().Fault, ProbeOrderFault.Locked).ShouldBeTrue(); + Rename(1).ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order)).Result.ShouldBeOfType>(); + } + + // 4: a detail reaches the body only when set, and the default response is unchanged. + [Fact] + public async Task A_detail_reaches_the_problem_only_when_there_is_one() + { + await using var app = await StartAsync(); + + var plain = (await ProblemAsync(app, "/orders/2", HttpStatusCode.Conflict)).AsObject(); + plain.Select(member => member.Key).Order().ShouldBe(["fault", "status", "title", "traceId", "type"]); + + var detailed = (await ProblemAsync(app, "/detailed/orders/2", HttpStatusCode.Conflict)).AsObject(); + detailed["detail"]!.GetValue().ShouldBe("Order 7 is called that."); + detailed["fault"]!.GetValue().ShouldBe("NameTaken", "the mapping stays keyed on the enum"); + } + + /// + /// A closed set answered by its kinds: 404, 409 and 403 with nothing mapped by hand, and a member's + /// description as the problem's detail, where the member has one. + /// + [Fact] + public async Task A_fault_set_is_answered_by_its_kinds_and_its_descriptions() + { + await using var app = await StartAsync(); + + var notFound = await ProblemAsync(app, "/catalog/9", HttpStatusCode.NotFound); + notFound["fault"]!.GetValue().ShouldBe("NotFound"); + notFound["detail"]!.GetValue().ShouldBe("The order does not exist."); + + (await ProblemAsync(app, "/catalog/2", HttpStatusCode.Conflict))["detail"]!.GetValue().ShouldBe("Another order has that name."); + (await ProblemAsync(app, "/catalog/3", HttpStatusCode.Forbidden)).AsObject().ContainsKey("detail").ShouldBeFalse("a member that must not explain itself has no detail"); + + var responses = (await DocumentAsync(app))["paths"]!["/catalog/{id}"]!["get"]!["responses"]!.AsObject(); + responses.Select(response => response.Key).Order().ShouldBe(["200", "403", "404", "409"]); + } + + // 5: WithFaultStatus changes the status for that endpoint only, in the response and in its document entry. + [Fact] + public async Task A_status_per_endpoint_changes_that_endpoint_alone() + { + await using var app = await StartAsync(); + + await ProblemAsync(app, "/strict/orders/2", HttpStatusCode.UnprocessableEntity); + await ProblemAsync(app, "/orders/2", HttpStatusCode.Conflict); + + var paths = (await DocumentAsync(app))["paths"]!; + paths["/strict/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "422"]); + paths["/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "409"]); + } + + [Fact] + public void A_status_per_endpoint_is_an_error_status() => + Should.Throw(() => WebApplication.CreateBuilder().Build().MapGet("/x", () => Rename(1).ToHttpResult()).WithFaultStatus(OrderFault.NameTaken, 200)) + .ParamName.ShouldBe("status"); + + // 6: the startup check refuses a WithFaultStatus for a fault type the endpoint does not answer with. + [Fact] + public async Task A_status_for_a_fault_type_the_endpoint_does_not_answer_with_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + app.MapGet("/stray/{id:int}", (int id) => Find(id).ToHttpResult()).WithFaultStatus(OrderFault.NameTaken, 422))); + + refusal.Message.ShouldContain("WithFaultStatus(OrderFault.NameTaken, 422) is set on"); + refusal.Message.ShouldContain("which answers with no OrderFault"); + } + + // 7: MVC: the attribute reaches the response and the operation transformer. + [Fact] + public async Task An_MVC_action_s_status_attribute_reaches_the_response_and_the_document() + { + await using var app = await StartAsync(mvc: typeof(RenamesController)); + + await ProblemAsync(app, "/mvc/orders/2", HttpStatusCode.UnprocessableEntity); + (await DocumentAsync(app))["paths"]!["/mvc/orders/{id}"]!["put"]!["responses"]!.AsObject() + .Select(response => response.Key).Order().ShouldBe(["200", "404", "422"]); + } + + [Fact] + public async Task An_attribute_naming_no_member_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: typeof(MisnamedController))); + + refusal.Message.ShouldContain("[FaultStatus(\"Taken\", 422)] names no member of OrderFault"); + } + + // 8: the endpoint's own success: 201 with its body in the document, and a Location header. + [Fact] + public async Task The_endpoint_s_own_success_is_answered_and_described() + { + await using var app = await StartAsync(); + + using var response = await app.GetTestClient().PostAsync("/orders", content: null, TestContext.Current.CancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + response.Headers.Location.ShouldBe(new Uri("/orders/1", UriKind.Relative)); + + var responses = (await DocumentAsync(app))["paths"]!["/orders"]!["post"]!["responses"]!.AsObject(); + responses.Select(entry => entry.Key).Order().ShouldBe(["201", "404", "409"]); + responses["201"]!["content"]!["application/json"]!["schema"].ShouldNotBeNull("the success's body is described"); + } + + // 9: CustomizeProblemDetails sees the fault for a problem ToHttpResult wrote, and for no other. + [Fact] + public async Task CustomizeProblemDetails_sees_the_fault_of_a_problem_ToHttpResult_wrote_and_of_no_other() + { + await using var app = await StartAsync(services: services => + { + Responses(services); + services.AddProblemDetails(options => options.CustomizeProblemDetails = context => + context.ProblemDetails.Extensions["code"] = context.HttpContext.Features.Get() is { } fault + ? $"{fault.FaultType.Name}:{fault.Name}:{fault.Detail ?? "-"}" + : "none"); + }); + + (await ProblemAsync(app, "/detailed/orders/2", HttpStatusCode.Conflict))["code"]!.GetValue().ShouldBe("OrderFault:NameTaken:Order 7 is called that."); + (await ProblemAsync(app, "/catalog/9", HttpStatusCode.NotFound))["code"]!.GetValue().ShouldBe("ProbeOrderFault:NotFound:The order does not exist."); + (await ProblemAsync(app, "/elsewhere", (HttpStatusCode)418))["code"]!.GetValue().ShouldBe("none"); + } + + /// The feature is on the request only while its problem is written: whatever the request writes afterwards does not see it. + [Fact] + public async Task The_fault_is_off_the_request_once_its_problem_is_written() + { + var after = new List(); + await using var app = await StartAsync(endpoints: endpoints => + endpoints.MapPut("/observed/orders/{id:int}", (int id) => new Observed(Rename(id).ToHttpResult(), after))); + + await ProblemAsync(app, "/observed/orders/2", HttpStatusCode.Conflict); + + after.ShouldHaveSingleItem().ShouldBeNull(); + } + + /// Executes the endpoint's result, then records what the request's features hold. + private sealed class Observed(IResult inner, List after) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + await inner.ExecuteAsync(httpContext); + after.Add(httpContext.Features.Get()); + } + } + + // 10: qualified fault names are opt-in; the default stays the bare member name. + [Fact] + public async Task Qualified_fault_names_are_opt_in() + { + await using var app = await StartAsync(services: services => + { + Responses(services); + services.Configure(options => options.QualifiedFaultNames = true); + }); + + (await ProblemAsync(app, "/orders/9", HttpStatusCode.NotFound))["fault"]!.GetValue().ShouldBe("OrderFault.NotFound"); + (await ProblemAsync(app, "/catalog/9", HttpStatusCode.NotFound))["fault"]!.GetValue().ShouldBe("ProbeOrderFault.NotFound"); + } + + // 11: fault stays a string on the shared problem component; no enum across fault types. + [Fact] + public async Task The_problem_component_is_shared_and_says_nothing_of_any_fault_type() + { + await using var app = await StartAsync(); + + var document = await DocumentAsync(app); + var problem = document["components"]!["schemas"]!["ProblemDetails"]!.ToJsonString(); + problem.ShouldNotContain("NameTaken"); + problem.ShouldNotContain("\"enum\""); + } + + /// A member a cast makes is refused for a closed set too, since an instance that is no member has no answer. + [Fact] + public void A_fault_set_member_is_one_of_its_members() + { + var services = new ServiceCollection(); + services.AddFaultResponses(); + services.Any(descriptor => descriptor.ServiceType == typeof(FaultResponses)).ShouldBeTrue(); + Should.Throw(() => services.AddFaultResponses()).Message.ShouldContain("mapped already"); + } + + private static async Task StartAsync(Action? services = null, Action? endpoints = null, Type? mvc = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => options.AddTypeKit()); + (services ?? Responses)(builder.Services); + if (mvc is not null) + { + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyControllers(mvc)); + }); + } + + var app = builder.Build(); + app.MapOpenApi(); + Endpoints(app); + endpoints?.Invoke(app); + if (mvc is not null) app.MapControllers(); + + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return app; + } + + private static async Task ProblemAsync(WebApplication app, string path, HttpStatusCode status) + { + using var response = path.Contains("orders/", StringComparison.Ordinal) && !path.StartsWith("/catalog", StringComparison.Ordinal) + ? await app.GetTestClient().PutAsync(path, content: null, TestContext.Current.CancellationToken) + : await app.GetTestClient().GetAsync(path, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(status, path); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json"); + + return JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + } + + private static async Task DocumentAsync(WebApplication app) => + JsonNode.Parse(await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!; + + private sealed class OnlyControllers(params Type[] controllers) : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) + { + foreach (var controller in controllers) feature.Controllers.Add(controller.GetTypeInfo()); + } + } + + [ApiController] + public sealed class RenamesController : ControllerBase + { + [HttpPut("/mvc/orders/{id:int}")] + [FaultStatus(nameof(OrderFault.NameTaken), StatusCodes.Status422UnprocessableEntity)] + public Results, FaultProblem> Rename(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + } + + [ApiController] + public sealed class MisnamedController : ControllerBase + { + [HttpPut("/mvc/misnamed/{id:int}")] + [FaultStatus("Taken", StatusCodes.Status422UnprocessableEntity)] + public Results, FaultProblem> Rename(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + } +} diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 6f48277..2559c26 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -274,3 +274,35 @@ public sealed partial class ProbeKeyword : IClosedSet public static readonly ProbeKeyword New = new(); public static readonly ProbeKeyword Default = new(); } + +/// +/// A fault set: each member's kind, from which a host derives its status, and a description where the +/// member has one. Locked must not explain itself, so it has none. +/// +public sealed partial class ProbeOrderFault : IFaultSet +{ + public static readonly ProbeOrderFault NotFound = new(FaultKind.NotFound, "The order does not exist."); + public static readonly ProbeOrderFault NameTaken = new(FaultKind.Conflict, "Another order has that name."); + public static readonly ProbeOrderFault Locked = new(FaultKind.Forbidden, null); + + private ProbeOrderFault(FaultKind kind, string? description) + { + Kind = kind; + Description = description; + } + + public FaultKind Kind { get; } + + public string? Description { get; } +} + +/// A fault set with a member whose kind is default, which is no kind: refused where the faults are mapped. +public sealed partial class ProbeKindlessFault : IFaultSet +{ + public static readonly ProbeKindlessFault Known = new(FaultKind.Invalid); + public static readonly ProbeKindlessFault Unset = new(default); + + private ProbeKindlessFault(FaultKind kind) => Kind = kind; + + public FaultKind Kind { get; } +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index f9f706a..d8b4d68 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -620,6 +620,25 @@ ProbeKeyword static All : IReadOnlyList static Parse(String) : ProbeKeyword static TryParse(String, out ProbeKeyword) : Boolean +ProbeKindlessFault + Kind : FaultKind + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IFault + implements IFaultSet + implements IParsable + nested ProbeKindlessFaultJsonConverter : JsonConverter + nested ProbeKindlessFaultTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeKindlessFault + static TryParse(String, out ProbeKindlessFault) : Boolean ProbeLabel $() : ProbeLabel CompareTo(Object) : Int32 @@ -817,6 +836,26 @@ ProbeName static op_LessThanOrEqual(ProbeName, ProbeName) : Boolean extension static ProbeNameExtensions.GetValue(ProbeName) : String extension static ProbeNameExtensions.ValueOrNull(ProbeName?) : String +ProbeOrderFault + Description : String + Kind : FaultKind + Match(Func, Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IFault + implements IFaultSet + implements IParsable + nested ProbeOrderFaultJsonConverter : JsonConverter + nested ProbeOrderFaultTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeOrderFault + static TryParse(String, out ProbeOrderFault) : Boolean ProbeOverride CompareTo(Object) : Int32 CompareTo(ProbeOverride) : Int32 diff --git a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs new file mode 100644 index 0000000..478a3c4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs @@ -0,0 +1,210 @@ +using System.Net; +using System.Text.Json.Nodes; +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; +using CodoMetis.TypeKit.ClosedSets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReadmeSamples.Web.Faults; + +public enum PaymentFault { Declined, Duplicate } + +// sample: CodoMetis.TypeKit.AspNetCore/fault-set +public sealed partial class AccountFault : IFaultSet +{ + public static readonly AccountFault NotFound = new(FaultKind.NotFound, "The account does not exist."); + public static readonly AccountFault EmailTaken = new(FaultKind.Conflict, "Another account uses that email address."); + public static readonly AccountFault Locked = new(FaultKind.Forbidden, null); // a refusal that must not explain itself + + private AccountFault(FaultKind kind, string? description) { Kind = kind; Description = description; } + + public FaultKind Kind { get; } + public string? Description { get; } +} +// end sample + +public sealed record Account(int Id, string Email); + +public sealed record NewAccount(string Email); + +public interface IAccounts +{ + Result Open(NewAccount body); + + Result> Rename(int id, string email); +} + +public static class Faults +{ + public static void Responses(WebApplicationBuilder builder) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-response + builder.Services.AddFaultResponses(fault => fault switch + { + PaymentFault.Declined => FaultResponse.Status(StatusCodes.Status402PaymentRequired).WithType(new Uri("https://example.com/problems/declined")), + PaymentFault.Duplicate => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + // end sample + + // sample: CodoMetis.TypeKit.AspNetCore/fault-set-mapping + builder.Services.AddFaultResponses(); // every member by its kind, its description as the detail + // end sample + } + + public static void Endpoints(WebApplication app, IAccounts accounts) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-status + app.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()) + .WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); + // end sample + + // sample: CodoMetis.TypeKit.AspNetCore/fault-success + app.MapPost("/v2/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult(account => TypedResults.Created($"/accounts/{account.Id}", account))); + // end sample + + // sample: CodoMetis.TypeKit.AspNetCore/fault-detail + app.MapPut("/accounts/{id:int}/email", (int id, NewAccount body) => accounts.Rename(id, body.Email).ToHttpResult()); + // end sample + } + + public static Result> Taken() + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-detail-domain + Result> taken = Result.Error(Fault.Of(AccountFault.EmailTaken, "The address has been in use since 3 May.")); + // end sample + + return taken; + } + + public static void Shaped(WebApplicationBuilder builder) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-feature + builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => + { + if (context.HttpContext.Features.Get() is { } fault) + context.ProblemDetails.Extensions["code"] = $"ACC-{fault.Name}"; // the host's own extension, for this fault only + }); + builder.Services.Configure(options => options.QualifiedFaultNames = true); // "fault": "AccountFault.EmailTaken" + // end sample + } +} + +// Internal, so no host of this assembly discovers it: the sample is the action's declaration. +[ApiController] +internal sealed class AccountsController(IAccounts accounts) : ControllerBase +{ + // sample: CodoMetis.TypeKit.AspNetCore/fault-status-mvc + [HttpPost("/mvc/accounts")] + [FaultStatus(nameof(AccountFault.EmailTaken), StatusCodes.Status422UnprocessableEntity)] + public Results, FaultProblem> Open(NewAccount body) => accounts.Open(body).ToHttpResult(); + // end sample +} + +/// A domain that answers every call with one outcome, for the tests below. +public sealed class FixedAccounts(Result open) : IAccounts +{ + public Result Open(NewAccount body) => open; + + public Result> Rename(int id, string email) => Faults.Taken(); +} + +public sealed class FaultResponseSampleTests +{ + private static async Task StartAsync(Result open, bool shaped = false) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => options.AddTypeKit()); + Faults.Responses(builder); + if (shaped) Faults.Shaped(builder); + + var app = builder.Build(); + app.MapOpenApi(); + Faults.Endpoints(app, new FixedAccounts(open)); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private static StringContent Body() => new("""{"email":"ann@example.com"}""", System.Text.Encoding.UTF8, "application/json"); + + [Fact] + public async Task A_fault_set_is_answered_by_kind_and_the_endpoint_s_status_where_it_sets_one() + { + await using var app = await StartAsync(Result.Error(AccountFault.EmailTaken)); + + using var strict = await app.GetTestClient().PostAsync("/accounts", Body(), TestContext.Current.CancellationToken); + strict.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity, "WithFaultStatus"); + + using var byKind = await app.GetTestClient().PostAsync("/v2/accounts", Body(), TestContext.Current.CancellationToken); + byKind.StatusCode.ShouldBe(HttpStatusCode.Conflict, "the member's kind"); + var problem = JsonNode.Parse(await byKind.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + problem["detail"]!.GetValue().ShouldBe("Another account uses that email address."); + } + + [Fact] + public async Task The_endpoint_s_own_success_is_a_201_with_its_location() + { + await using var app = await StartAsync(new Account(7, "ann@example.com")); + + using var created = await app.GetTestClient().PostAsync("/v2/accounts", Body(), TestContext.Current.CancellationToken); + created.StatusCode.ShouldBe(HttpStatusCode.Created); + created.Headers.Location!.ToString().ShouldBe("/accounts/7"); + } + + [Fact] + public async Task An_occurrence_s_detail_replaces_the_member_s_description() + { + await using var app = await StartAsync(new Account(7, "ann@example.com")); + + using var response = await app.GetTestClient().PutAsync("/accounts/7/email", Body(), TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict); + problem["detail"]!.GetValue().ShouldBe("The address has been in use since 3 May."); + } + + [Fact] + public async Task The_host_shapes_the_problem_and_qualifies_the_fault_name() + { + await using var app = await StartAsync(Result.Error(AccountFault.EmailTaken), shaped: true); + + using var response = await app.GetTestClient().PostAsync("/v2/accounts", Body(), TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + + problem["fault"]!.GetValue().ShouldBe("AccountFault.EmailTaken"); + problem["code"]!.GetValue().ShouldBe("ACC-AccountFault.EmailTaken"); + } + + [Fact] + public async Task A_type_URI_reaches_the_problem_and_the_document() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => options.AddTypeKit()); + Faults.Responses(builder); + await using var app = builder.Build(); + app.MapOpenApi(); + app.MapPost("/payments", () => Result.Error(PaymentFault.Declined).ToHttpResult()); + await app.StartAsync(TestContext.Current.CancellationToken); + + using var response = await app.GetTestClient().PostAsync("/payments", content: null, TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + response.StatusCode.ShouldBe(HttpStatusCode.PaymentRequired); + problem["type"]!.GetValue().ShouldBe("https://example.com/problems/declined"); + + var document = JsonNode.Parse(await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!; + document["paths"]!["/payments"]!["post"]!["responses"]!["402"]!["description"]!.GetValue() + .ShouldBe("Payment Required. Problem types: https://example.com/problems/declined"); + } +} diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index 626f205..e9b604f 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -578,6 +578,18 @@ public sealed partial class OrderStatus : IClosedSet public static readonly OrderStatus Shipped = new(); } +// A fault set: each member's kind gives its status, its description the problem's detail. +public sealed partial class AccountFault : IFaultSet +{ + public static readonly AccountFault NotFound = new(FaultKind.NotFound, "The account does not exist."); + public static readonly AccountFault EmailTaken = new(FaultKind.Conflict, null); + + private AccountFault(FaultKind kind, string? description) { Kind = kind; Description = description; } + + public FaultKind Kind { get; } + public string? Description { get; } +} + // One per JSON strategy: a reader method (Guid), the serializer's contract for a number, the // fallback with a built-in converter (Uri, an enum), a string, and parsing through a TypeConverter. public readonly partial record struct OrderId : IValue; @@ -650,6 +662,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; using CodoMetis.TypeKit.ValueObjects; using Shop; @@ -705,6 +718,12 @@ builder.Services.AddFaultResponses(fault => fault switch CodeFault.NotUpperCase => StatusCodes.Status422UnprocessableEntity, _ => throw new ArgumentOutOfRangeException(nameof(fault)), }); +// A fault set answered by its kinds, and the fault on the request for the host's own extension. +builder.Services.AddFaultResponses(); +builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => +{ + if (context.HttpContext.Features.Get() is { } fault) context.ProblemDetails.Extensions["code"] = $"ACC-{fault.Name}"; +}); await using var app = builder.Build(); app.MapOpenApi(); @@ -713,6 +732,10 @@ app.MapPost("/orders", (OrderView body) => body.Amount); app.MapPost("/tracking", (Tracking body) => body.Status.Name); app.MapPatch("/orders/{id}", (OrderId id, OrderPatch patch) => $"{patch.Code.Match(code => $"Value({code.Value})", () => "Null", () => "Absent")} {patch.Note.Match(note => $"Value({note})", () => "Null", () => "Absent")}"); app.MapGet("/codes/{text}", (string text) => Code.Create(text).ToHttpResult()); +app.MapGet("/accounts/{id}", (string id) => (id == "taken" ? Result.Error(AccountFault.EmailTaken) : Result.Error(AccountFault.NotFound)).ToHttpResult()); +app.MapGet("/strict/accounts/{id}", (string id) => Result.Error(AccountFault.EmailTaken).ToHttpResult()).WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); +app.MapGet("/detailed/accounts/{id}", (string id) => Result>.Error(Fault.Of(AccountFault.EmailTaken, "In use since 3 May.")).ToHttpResult()); +app.MapPost("/accounts", () => Result.Success("a1").ToHttpResult(id => TypedResults.Created($"/accounts/{id}", id))); await app.StartAsync(); using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) }; @@ -743,6 +766,21 @@ await Check("result-fault", async () => return $"{(int)response.StatusCode} {response.Content.Headers.ContentType?.MediaType} {problem?["fault"]}"; }); Console.WriteLine($"result-responses={string.Join(",", document?["paths"]?["/codes/{text}"]?["get"]?["responses"]?.AsObject().Select(r => r.Key) ?? [])}"); +async Task Problem(string path) +{ + var response = await http.GetAsync(path); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync()); + return $"{(int)response.StatusCode} {problem?["fault"]} {problem?["detail"]?.ToString() ?? "-"} {problem?["code"]}"; +} +await Check("fault-kind", () => Problem("/accounts/missing")); +await Check("fault-strict", () => Problem("/strict/accounts/x")); +await Check("fault-detail", () => Problem("/detailed/accounts/x")); +await Check("fault-created", async () => +{ + var response = await http.PostAsync("/accounts", content: null); + return $"{(int)response.StatusCode} {response.Headers.Location}"; +}); +Console.WriteLine($"fault-responses={string.Join(",", document?["paths"]?["/strict/accounts/{id}"]?["get"]?["responses"]?.AsObject().Select(r => r.Key) ?? [])}"); await app.StopAsync(); public sealed record Line(OrderId Id, Code Code, Name Name, Quantity Quantity, Amount Amount, Link Link, Weekday Weekday, @@ -915,6 +953,11 @@ assert_contains "bound=200" "$work/aot.out" "a route para assert_contains 'result-success="ABC"' "$work/aot.out" "ToHttpResult answers a success with its value" assert_contains "result-fault=422 application/problem+json NotUpperCase" "$work/aot.out" "and a fault as problem details with its mapped status" assert_contains "result-responses=200,400,422" "$work/aot.out" "the document lists the value and each mapped status" +assert_contains "fault-kind=404 NotFound The account does not exist. ACC-NotFound" "$work/aot.out" "a fault set is answered by its kind, its description the detail, the host's extension from the fault feature" +assert_contains "fault-strict=422 EmailTaken - ACC-EmailTaken" "$work/aot.out" "an endpoint's status replaces the kind's" +assert_contains "fault-detail=409 EmailTaken In use since 3 May. ACC-EmailTaken" "$work/aot.out" "a Fault's detail reaches the problem" +assert_contains "fault-created=201 /accounts/a1" "$work/aot.out" "the endpoint's own success, with its Location" +assert_contains "fault-responses=200,404,422" "$work/aot.out" "the document lists the endpoint's status in place of the kind's" assert_contains "malformed=400" "$work/aot.out" "a malformed route parameter is a 400" assert_contains 'patch=Value(ABC) Null' "$work/aot.out" "a Patch reads a value object and a null through the context under Native AOT" assert_contains 'set-json={"Status":"Shipped","Counts":{"Open":1}}' "$work/aot.out" "a closed set writes its name through the context, as a value and as a dictionary key" From 07ffe04ca1620fac560091b9ccd5c4697f52fb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 17:54:44 +0200 Subject: [PATCH 027/147] feat: CMTK0003 is an Error, its gaps closed first, and the 1.5.1 baseline's verdict Step 9 of 2.0.0, the deliberate breaks (decision 50). CMTK0003, a dropped Result or Option, is the one Warning raised to Error. The bar: every report can be answered in the code by a form that says what is meant, never by a suppression alone, and the reach is complete, since an Error cannot learn within the major. A drop nothing depends on is correct, and '_ =', a cast or a loop variable named '_' says so. Its reach is a return type, not a list, so it was complete once its 1.x gaps were closed: - Wait on a task of a result the expression made, whatever reads the bool a timeout overload returns; - tasks of results the statement made converted to plain tasks, an array, a sequence or a span of them, as Task.WaitAll and WaitAny take; an explicit cast stays silent, as a cast to Task does; - a foreach or await foreach over results its header made whose body never reads the iteration variable; - a lazy sequence of results a call made without being given any, materialised by ToList or ToListAsync and dropped. CMTK0005, CMTK0006, CMTK0008 and CMTK0009 stay Warnings: correct code looks the same and has only a suppression to say so (an array filled in a loop, a field set in OnInitialized), or the reach would freeze (CMTK0008's four known gaps, CMTK0009's list of framework methods). CMTK0010-0012 needed nothing: new at Error in this major, reporting only on what it adds. The 1.5.1 baseline over all seven packages: under package validation's default rules one difference, CP0021 on FaultProblem, suppressed alone (decision 48); with the attribute and parameter-name rules on as well, 29 compiler-emitted attributes (state machines renumbered by added members, nullable-context bytes) and no renamed parameter. Guards: AnalyzerReleases records the change (RS2001 refuses a descriptor that disagrees, RS2007 a change that is none); AnalyzerHelpLinkTests holds the README's severity column to every descriptor; the build-outcome consumer no longer raises CMTK0003 itself, so its reports prove the default. Eleven seeds for the new forms, the severity seeded back to Warning (15 analyzer tests and 3 build-outcome tests fail), each failing its guard. Consumer smoke test passed, Native AOT included. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +- design/plan.md | 51 ++++-- .../AnalyzerReleases.Unshipped.md | 6 + .../IgnoredOutcomeAnalyzer.cs | 142 ++++++++++++++--- src/CodoMetis.TypeKit.Analyzers/README.md | 36 ++++- src/CodoMetis.TypeKit/README.md | 4 +- .../AnalyzerHelpLinkTests.cs | 25 ++- .../GeneratedCodeTests.cs | 2 +- .../IgnoredOutcomeAnalyzerTests.cs | 145 +++++++++++++++++- .../BuildOutcomeTests.cs | 2 +- .../Analyzers/RuleSamples.cs | 19 +++ 11 files changed, 399 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e65b8f..88a98e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,11 @@ section is the GitHub release's notes. ## 2.0.0 — Unreleased A major version: it targets `net10.0` and Metalama 2026.1 as 1.x does, and the breaks are the ones -Semantic Versioning reserves for a major, listed under Changed as they land. +Semantic Versioning reserves for a major, listed under Changed. Package validation against 1.5.1 +finds one difference in the public surface, `FaultProblem`'s constraint, which still accepts +every type argument it accepted; the others are behaviour no compatibility check reads, CMTK0003 at +Error and `FirstOrNone` on a query. The new Error rules, CMTK0010 to CMTK0012, report only on what +this release adds, so nothing that built against 1.5.1 meets them. ### Added @@ -139,6 +143,14 @@ Semantic Versioning reserves for a major, listed under Changed as they land. compile errors that say to order the query and take the first, or to call `AsEnumerable()`. - **`CodoMetis.TypeKit.AspNetCore`**: `FaultProblem` takes any `notnull` fault type, an enum or a closed set, where it took `struct, Enum`; every type argument it accepted is still accepted. +- **`CodoMetis.TypeKit.Analyzers`**: CMTK0003, a dropped `Result` or `Option`, is an Error where it was + a Warning. A drop that nothing depends on is correct, and `_ = …`, a cast, or a loop variable named + `_` says so in the code, so the error never needs a suppression. It also reports what it missed: + `Wait` on a task of a result the expression made; tasks of results the statement made, passed to + `Task.WaitAll` or `Task.WaitAny`; a `foreach` or `await foreach` over results its header made whose + body never reads them; and a lazy sequence of results a call made, materialised by `ToList()` or + `ToListAsync()` and dropped. CMTK0005, CMTK0006, CMTK0008 and CMTK0009 stay Warnings, since correct + code can look the same and has nothing but a suppression to say so, and CMTK0007 stays Info. ## 1.5.1 — 2026-10-11 diff --git a/design/plan.md b/design/plan.md index b136b51..3087011 100644 --- a/design/plan.md +++ b/design/plan.md @@ -445,8 +445,9 @@ Each phase ends green, and its guards have been proven by seeding the defect at the named release on 2026-09-28, the next major in preview included) and "Built with Metalama". The Generators README links to the comparison. Both are GitHub-only and can be corrected without a release. - - Found and left for 1.x: CMTK0003 over `.Wait()` and `await foreach`; CMTK0008 over tuple `==`, - `Contains` and `is var`, and composite join keys (all Warnings, decision 34). Raw Metalama errors + - Found and left for 1.x: CMTK0003 over `.Wait()` and `await foreach` (closed in 2.0.0 before the + rule became an Error, decision 50); CMTK0008 over tuple `==`, `Contains` and `is var`, and + composite join keys (a Warning, decision 34). Raw Metalama errors for a value object named after a generated member (`From`, `TryFrom`, `MinValue`), a wrapped type whose `Parse` does not return it or that is abstract, and a public base member whose signature matches a generated one (LAMA0500). `ToString()` of a default `Uri`/`Version` value object throws; `IConvertible` quotes @@ -516,7 +517,7 @@ Each phase ends green, and its guards have been proven by seeding the defect per type, through `GeneratedValueObjectAttribute.Accept`), Newtonsoft.Json under decision 26's parity, and configuration binding, which the configuration binder's source generator does not do for `TypeConverter`/`IParsable` types (dotnet/runtime#83599). The CMTK0003/CMTK0008 gaps above - stay Warnings (decision 34). + stay Warnings (decision 34); CMTK0003's are closed in 2.0.0 (decision 50). - **Ranges are not a TypeKit family**: CodoMetis.ValueRanges models them. A value object over one (`IValidatedValue`) works with both packages as they ship, measured 2026-10-11 in [spikes/RangeValueObjects](../spikes/RangeValueObjects/README.md): @@ -619,8 +620,19 @@ Each phase ends green, and its guards have been proven by seeding the defect `ToHttpResult(success)`, `IFaultFeature` and `FaultResponseOptions.QualifiedFaultNames` (decision 49). `FaultProblem`'s constraint relaxed to `notnull`, the first difference the 1.5.1 baseline reports, suppressed alone (decision 48). - 9. **The breaks**: decision 34's promotions, the two rules at Error, and the baseline's verdict - on each intended break. + 9. **The breaks** ✅ 2026-10-13 (decision 50). CMTK0003 is the one Warning raised to Error, after + its gaps were closed: `Wait` on a task the expression made, tasks of results made in the + statement converted to plain ones (`Task.WaitAll`, `WaitAny`), a `foreach` or `await foreach` + whose body never reads the results its header made, and a lazy sequence of results a call made, + materialised and dropped (eleven seeds, each failing its guard; an explicit cast to plain tasks + stays silent, as a cast to `Task` does). The release-tracking file + records the change (RS2001 refuses a descriptor that disagrees, RS2007 a change that is none), + `AnalyzerHelpLinkTests` holds the README's severity column to every descriptor, and the + build-outcome consumer no longer raises CMTK0003 itself, so its reports there prove the + default. CMTK0010–0012 needed nothing: they ship at Error in this major and report only on + what it adds. The 1.5.1 baseline, run over all seven packages with the attribute and + parameter-name rules on as well, reports the one suppressed difference and compiler-emitted + attributes only. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the @@ -816,10 +828,10 @@ Each phase ends green, and its guards have been proven by seeding the defect ready-made type appears, a minor-version `CodoMetis.TypeKit.Text` holding `WrittenText` and `IssuedIdentifier` under those conditions is additive; removing one after shipping is not. No spike: every claim is a unit test over a string. - - **What 2.0.0 breaks**: Warnings promoted to Errors (decision 34), CMTK0010 and CMTK0011 at - Error, and whatever else the 1.5.0 baseline reports as intended. Whether an intended break is - suppressed per break or the baseline lapses for 2.0.0 is decided at the first one, so an - unintended break still fails the pack. + - **What 2.0.0 breaks** (decision 50): CMTK0003 at Error, the one Warning raised; CMTK0010–0012 + new at Error; `FaultProblem`'s constraint, suppressed alone against the 1.5.1 baseline + (decision 48); `FirstOrNone`/`FirstOrError` binding to the query overloads, and `LastOrNone` + on a query a compile error. - **What it does not wait for** (decision 44): Metalama 2027.0 stable, .NET 11 and C# 15 unions. The compiler-made union types have a public `object? Value` and are serialized by System.Text.Json, so `Option`/`Result` take part in their pattern matching only after a spike @@ -1535,6 +1547,25 @@ Still open: - **An MVC action names its member in an attribute**, `[FaultStatus(nameof(…), 422)]`, since an attribute takes only constants; a name no member has stops the host. +50. **Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0** (2026-10-13, phase 13). Decision 34 + lets a major raise a Warning; the bar for raising one is that every report can be answered in the + code by a form that says what is meant, never by a suppression alone, and that its reach is + complete, since an Error cannot learn within the major. CMTK0003 meets both: `_ =`, a cast or a + loop variable named `_` says a drop is meant, and its reach is a return type rather than a list + of methods, complete once its 1.x gaps were closed (`Wait`, `Task.WaitAll`, a loop that never + reads its results, a lazy sequence materialised). The others stay. CMTK0005's array filled in a + loop and CMTK0006's member set in a second step (`OnInitialized`, `InitializeAsync`, a private + field `required` cannot reach) have only a suppression to say so. CMTK0008's shared identities + are answered by a cast an IDE reports as redundant, and its four known gaps would be frozen for + 2.x. CMTK0009's reach is a list of framework methods, which the `net11.0` target of a 2.x minor + may lengthen, and a sequence checked for emptiness first is correct. CMTK0007 reports test + theories. The 1.5.1 baseline's verdict, over all seven packages: under package validation's + default rules one difference, CP0021 on `FaultProblem` (decision 48); with the + attribute and parameter-name rules on as well, 29 compiler-emitted attributes (state machines + renumbered by members added before them, nullable-context bytes) and no renamed parameter. The + analyzer's severity and the query overloads' binding are behaviour no compatibility check reads; + the changelog states each. + ## 9. Option and Result `Result`, `Result` and `Option`, with the `Option.None()`, `Result.Success()` and @@ -1693,7 +1724,7 @@ exist. |---|---|---|---| | CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | | CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | -| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run. CA1806 can only enforce this per method name, not per return type | Warning | shipped in 1.0.0 | +| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 (decision 50), its 1.x gaps closed first | | CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | | CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | | CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md index ee34c25..5c6d382 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md @@ -8,3 +8,9 @@ Rule ID | Category | Severity | Notes CMTK0010 | Usage | Error | TypeArgumentConstraintAnalyzer, a type argument that breaks [Abstract], [Sealed], [Record] or [Concrete] on its parameter CMTK0011 | Usage | Error | RequiredParameterlessConstructorAnalyzer, a concrete type under [RequireParameterlessConstructor] without the constructor it requires CMTK0012 | Usage | Error | PositionalMatchArgumentAnalyzer, a closed set's Match given an argument by position + +### Changed Rules + +Rule ID | New Category | New Severity | Old Category | Old Severity | Notes +--------|--------------|--------------|--------------|--------------|------------------------------------------------------------- +CMTK0003 | Usage | Error | Usage | Warning | IgnoredOutcomeAnalyzer, raised in a major version; also a Wait, a WaitAll and a loop that drop results diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index 37f87cf..ade5f5d 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -10,8 +10,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// /// CMTK0003: a Result or Option that a call returns, or a collection of them that the -/// statement made, dropped by an expression statement, awaited or not, or by a conversion of its -/// Task to a plain Task, a method group's included. +/// statement made, dropped by an expression statement, awaited or not, by a conversion of its +/// Task to a plain Task, a method group's included, by Wait, or by a loop that +/// never reads them. /// /// /// @@ -38,7 +39,16 @@ namespace CodoMetis.TypeKit.Analyzers; /// that is dropped. The collection is reported when the statement made the results in it /// (). One that only passes on results the statement was given, as /// await Task.WhenAll(first, second) over tasks in variables or pending.ToList() do, -/// leaves them where they were and is silent. +/// leaves them where they were and is silent. A lazy sequence a call returns without being given any +/// result, orders.CancelEach(ids), makes its results as it is enumerated, so materialising it +/// in the statement made them too. +/// +/// +/// orders.CancelAsync(id).Wait(); blocks until the result exists and hands it to nobody, and +/// Task.WaitAll(ids.Select(orders.CancelAsync)) converts the tasks it made to plain ones, as a +/// single task's conversion does. A foreach or await foreach over results its header made, +/// whose body never reads the iteration variable, drains them: an iteration variable named _ +/// says the drop is intended. /// /// /// Tap, TapAsync, TapError and TapErrorAsync return their receiver unchanged, @@ -63,9 +73,9 @@ public sealed class IgnoredOutcomeAnalyzer : DiagnosticAnalyzer title: "Result or Option ignored", messageFormat: "The {0} that '{1}' returns is ignored, so its outcome is never seen. Handle it, or discard it with '_ =' if nothing depends on it.", category: "Usage", - DiagnosticSeverity.Warning, + DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "A Result carries an error and an Option an absence; dropping either, or a collection of them, as a statement, or converting its Task to a plain Task, means nobody decides what happens then. Assign it to '_' to make an intended drop explicit.", + description: "A Result carries an error and an Option an absence; dropping either, or a collection of them, as a statement, by converting its Task to a plain Task, by waiting for it, or by a loop that never reads them, means nobody decides what happens then. Assign it to '_', or name a loop's variable '_', to make an intended drop explicit.", helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.IgnoredOutcome) ); @@ -91,11 +101,16 @@ public override void Initialize(AnalysisContext context) start.Compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1"), start.Compilation.GetTypeByMetadataName("System.Threading.Tasks.ValueTask`1"), start.Compilation.GetSpecialType(SpecialType.System_Collections_Generic_ICollection_T), - start.Compilation.GetSpecialType(SpecialType.System_Collections_Generic_IReadOnlyCollection_T)); + start.Compilation.GetSpecialType(SpecialType.System_Collections_Generic_IReadOnlyCollection_T), + start.Compilation.GetSpecialType(SpecialType.System_Collections_Generic_IEnumerable_T), + start.Compilation.GetTypeByMetadataName("System.Collections.Generic.IAsyncEnumerable`1"), + start.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")); start.RegisterOperationAction(operation => Analyze(operation, symbols, tasks), OperationKind.ExpressionStatement); start.RegisterOperationAction(operation => AnalyzeConversion(operation, symbols, tasks), OperationKind.Conversion); start.RegisterOperationAction(operation => AnalyzeMethodGroup(operation, symbols, tasks), OperationKind.DelegateCreation); + start.RegisterOperationAction(operation => AnalyzeWait(operation, symbols, tasks), OperationKind.Invocation); + start.RegisterOperationAction(operation => AnalyzeLoop(operation, symbols, tasks), OperationKind.Loop); start.RegisterSyntaxNodeAction( node => AnalyzeUnbound(node, symbols), SyntaxKind.ExpressionStatement, @@ -141,7 +156,8 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym /// Whether the results in the collection returns were made in the /// statement, and so exist nowhere else: the call was given no result to pass on and made them /// itself, or a call in the statement returns a result or a task of one, or a method group of such - /// a method is passed on (ids.Select(orders.CancelAsync)). + /// a method is passed on (ids.Select(orders.CancelAsync)), or a call that was given no result + /// returns a lazy sequence of them, which makes them as it is enumerated. /// /// /// Task.WhenAll(first, second) over tasks in variables, and pending.ToList(), are given @@ -149,36 +165,85 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym /// private static bool MadeResults(IInvocationOperation invocation, TypeKitSymbols symbols, Tasks tasks) { - var given = Tasks.Mentions(invocation.Instance?.Type, symbols) - || invocation.TargetMethod.Parameters.Any(parameter => Tasks.Mentions(parameter.Type, symbols)); - if (!given) return true; + if (!Given(invocation, symbols)) return true; return invocation.Descendants().Any(operation => operation switch { - IInvocationOperation call => (symbols.OutcomeName(call.Type) ?? tasks.PendingOutcome(call.Type, symbols)) is not null && !ReturnsItsStoredReceiver(call, symbols), + IInvocationOperation call => (symbols.OutcomeName(call.Type) ?? tasks.PendingOutcome(call.Type, symbols)) is not null && !ReturnsItsStoredReceiver(call, symbols) + || tasks.SequenceOutcome(call.Type, symbols) is not null && !Given(call, symbols), IMethodReferenceOperation reference => (symbols.OutcomeName(reference.Method.ReturnType) ?? tasks.PendingOutcome(reference.Method.ReturnType, symbols)) is not null, _ => false }); } + /// Whether the call was handed something that can carry a result, as its receiver or an argument. + private static bool Given(IInvocationOperation invocation, TypeKitSymbols symbols) => + Tasks.Mentions(invocation.Instance?.Type, symbols) + || invocation.TargetMethod.Parameters.Any(parameter => Tasks.Mentions(parameter.Type, symbols)); + /// /// A call's Task of a result, or of a collection of results it made, converted implicitly to - /// a plain Task, which has no result to hand on. + /// a plain Task, which has no result to hand on; or tasks of results the statement made, + /// converted to plain tasks, an array, a sequence or a span of them, as Task.WaitAll takes. /// private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitSymbols symbols, Tasks tasks) { var conversion = (IConversionOperation)context.Operation; - if (!conversion.IsImplicit || !SymbolEqualityComparer.Default.Equals(conversion.Type, tasks.Plain)) return; - if (conversion.Operand is not IInvocationOperation invocation) return; + if (!conversion.IsImplicit || conversion.Operand is not IInvocationOperation invocation) return; - var outcome = tasks.PendingOutcome(invocation.Type, symbols) + string? outcome; + if (SymbolEqualityComparer.Default.Equals(conversion.Type, tasks.Plain)) + outcome = tasks.PendingOutcome(invocation.Type, symbols) ?? DroppedCollection(tasks.CollectionOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks); + else if (tasks.HoldsPlainTasks(conversion.Type)) + outcome = DroppedCollection(tasks.PendingOutcome(tasks.ElementOfSequence(invocation.Type), symbols), invocation, symbols, tasks); + else + return; + if (outcome is null) return; context.Report(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); } + /// + /// Wait on a task of a result, or of a collection of results, that the expression made: + /// orders.CancelAsync(id).Wait() blocks until the result exists and hands it to nobody, + /// whether the statement drops what Wait returns or an if reads it. A task still held + /// keeps its result. + /// + private static void AnalyzeWait(OperationAnalysisContext context, TypeKitSymbols symbols, Tasks tasks) + { + var wait = (IInvocationOperation)context.Operation; + + if (wait.TargetMethod is not { Name: "Wait", IsStatic: false } method || !SymbolEqualityComparer.Default.Equals(method.ContainingType, tasks.Plain)) return; + if (wait.Instance is null || Unwrap(wait.Instance) is not IInvocationOperation invocation) return; + + var outcome = tasks.PendingOutcome(invocation.Type, symbols) + ?? DroppedCollection(tasks.CollectionOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks); + if (outcome is null) return; + + context.Report(Diagnostic.Create(Rule, wait.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + } + + /// + /// A foreach or await foreach over results, or tasks of them, that its header made, + /// whose body never reads the iteration variable: await foreach (var outcome in + /// orders.CancelEachAsync(ids)) { } runs every cancellation and drops every result. A variable + /// named _ says the drop is intended; a collection held in a variable, a parameter or a field + /// keeps its results. + /// + private static void AnalyzeLoop(OperationAnalysisContext context, TypeKitSymbols symbols, Tasks tasks) + { + if (context.Operation is not IForEachLoopOperation { LoopControlVariable: IVariableDeclaratorOperation { Symbol: { Name: not "_" } variable } } loop) return; + if ((symbols.OutcomeName(variable.Type) ?? tasks.PendingOutcome(variable.Type, symbols)) is not { } outcome) return; + if (Unwrap(loop.Collection) is not IInvocationOperation invocation || !MadeResults(invocation, symbols, tasks)) return; + + if (loop.Body.Descendants().OfType().Any(reference => SymbolEqualityComparer.Default.Equals(reference.Local, variable))) return; + + context.Report(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), $"sequence of {outcome}s", invocation.TargetMethod.Name)); + } + /// /// A method group that returns a Task of a result, converted to a delegate that returns a /// plain Task: every call through the delegate drops the result, as the lambda @@ -263,7 +328,10 @@ private sealed class Tasks( INamedTypeSymbol? generic, INamedTypeSymbol? genericValueTask, INamedTypeSymbol? collection, - INamedTypeSymbol? readOnlyCollection) + INamedTypeSymbol? readOnlyCollection, + INamedTypeSymbol? sequence, + INamedTypeSymbol? asyncSequence, + INamedTypeSymbol? readOnlySpan) { public INamedTypeSymbol? Plain { get; } = plain; @@ -294,12 +362,48 @@ private sealed class Tasks( return symbols.OutcomeName(element) ?? PendingOutcome(element, symbols); } - private ITypeSymbol? ElementOf(INamedTypeSymbol type) + /// + /// Result or Option when is a sequence, lazy or not, of + /// results or of tasks of them: an array, or a type that is or implements + /// IEnumerable<T> or IAsyncEnumerable<T>. + /// + public string? SequenceOutcome(ITypeSymbol? type, TypeKitSymbols symbols) + { + var element = ElementOfSequence(type); + + return symbols.OutcomeName(element) ?? PendingOutcome(element, symbols); + } + + /// The element of an array, or of a type that is or implements IEnumerable<T> or IAsyncEnumerable<T>. + public ITypeSymbol? ElementOfSequence(ITypeSymbol? type) => + type switch + { + IArrayTypeSymbol array => array.ElementType, + INamedTypeSymbol named => ElementOf(named, sequence, asyncSequence), + _ => null + }; + + /// + /// Whether holds plain tasks, which have no result to hand on: an array + /// of Task, a sequence of them, or a ReadOnlySpan<Task>. + /// + public bool HoldsPlainTasks(ITypeSymbol? type) => + type switch + { + IArrayTypeSymbol array => SymbolEqualityComparer.Default.Equals(array.ElementType, Plain), + INamedTypeSymbol { TypeArguments.Length: 1 } named => SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], Plain) + && (SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, readOnlySpan) || ElementOfSequence(named) is not null), + _ => false + }; + + private ITypeSymbol? ElementOf(INamedTypeSymbol type) => ElementOf(type, collection, readOnlyCollection); + + private static ITypeSymbol? ElementOf(INamedTypeSymbol type, INamedTypeSymbol? first, INamedTypeSymbol? second) { foreach (var candidate in type.AllInterfaces.Insert(0, type)) { - if (SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, collection) - || SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, readOnlyCollection)) + if (SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, first) + || SymbolEqualityComparer.Default.Equals(candidate.OriginalDefinition, second)) return candidate.TypeArguments[0]; } diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 7a670d6..3535ecb 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -14,7 +14,7 @@ appears to: an instance that passed no factory, or a value object that was never |---|---|---| | CMTK0001 | Error | `default`, `default(T)`, `new T()`, `new()` and `new T { }` of a value object, an `Option`, a `Result`, or a struct marked `[RequireCustomInitialization]`. Also through a type parameter whose constraints make it one of those. A default that is only compared (`id == default`, `id.Equals(default)`, `ThrowIfEqual(id, default)`, `Assert.NotEqual(default, id)`) is a guard and is not reported, nor is one assigned to an `out` parameter of a Try method that returns `bool`. | | CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | -| CMTK0003 | Warning | A `Result` or `Option` that a call returns, or a collection of them that the statement made, dropped by a statement, awaited or not, or by converting its `Task` to a plain `Task`, a method group's included. | +| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection of them that the statement made, dropped by a statement, awaited or not, by converting its `Task` to a plain `Task`, a method group's included, by waiting for it, or by a loop that never reads it. `_` says the drop is meant. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core satellite. | | CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero is not reported. | | CMTK0006 | Warning | A field or auto-property of such a type in a class that no initializer, `required` or constructor sets, or a `required` one that a `[SetsRequiredMembers]` constructor does not set. | @@ -173,7 +173,26 @@ a method group of such a method is passed on, or the call that returns the colle result and made them itself, as `orders.CancelAll(ids);` does. A call given results that something still holds only passes them on and is silent: `Task.WhenAll` over tasks in variables, fields or parameters, or `pending.ToList()`. A lazy `IEnumerable`, such as a `Select` nobody enumerates, has -made nothing yet; CA1806 reports a LINQ result that is dropped. +made nothing yet; CA1806 reports a LINQ result that is dropped. A lazy sequence of results that a call +returns without being given any, `orders.CancelEach(ids)`, makes them as it is enumerated, so +`orders.CancelEach(ids).ToList();` is reported, and so is `await orders.CancelEachAsync(ids).ToListAsync();`. + +Waiting for a task drops its result as surely, and so does a loop that never reads what it walks: + +```csharp +orders.CancelAsync(id).Wait(); // CMTK0003: blocks until the Result exists, then forgets it +Task.WaitAll(ids.Select(orders.CancelAsync)); // CMTK0003: the tasks become plain ones +await foreach (var outcome in orders.CancelEachAsync(ids)) { } // CMTK0003: every Result, read by nobody +await foreach (var _ in orders.CancelEachAsync(ids)) { } // a variable named _ says the drop is meant +``` + +`Wait` is reported on a task the expression made, whether the statement drops the `bool` a timeout +overload returns or an `if` reads it. `Task.WaitAll` and `Task.WaitAny` take plain tasks, so tasks of +results the statement made are converted and their results dropped, as one task's conversion is; an +explicit cast to the plain tasks' type says that drop is intended. A +`foreach` or `await foreach` is reported when its header made the results, by the rule above, and +its body never reads the iteration variable. Tasks and collections that something still holds keep +their results and are silent. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` return their receiver unchanged, and so do the `Task` continuations `TapAsync` and `TapErrorAsync`, so `result.Tap(log);` and `await pending.TapAsync(log);` @@ -403,12 +422,13 @@ there too, put the line in a `.globalconfig` file, which applies to every file o The errors are the rules where the code is wrong whatever surrounds it: a `default` written out (CMTK0001), a value object nothing generates (CMTK0002), validation bypassed (CMTK0004), a type argument that breaks what its parameter declares (CMTK0010), a constructor a host will look for -and not find (CMTK0011), and branches bound to members by their order (CMTK0012). A -warning is what gets ignored, so these fail the build. The others are warnings, or for CMTK0007 Info, -because correct code can look the same: an array filled in a loop straight after it is created -(CMTK0005), a member a framework sets (CMTK0006), a sequence known not to be empty (CMTK0009), a -drop that nothing depends on (CMTK0003), or a comparison of two identities that really are shared -(CMTK0008). +and not find (CMTK0011), and branches bound to members by their order (CMTK0012). So is a result +dropped without saying so (CMTK0003): a drop that nothing depends on is correct, and `_` says it in +the code, where every reader sees it. A warning is what gets ignored, so these fail the build. The +others are warnings, or for CMTK0007 Info, because correct code can look the same and has nothing +but a suppression to say so: an array filled in a loop straight after it is created (CMTK0005), a +member set in a second step, such as a component's `OnInitialized` (CMTK0006), a sequence known not +to be empty (CMTK0009), or a comparison of two identities that really are shared (CMTK0008). Within a major version, an Error never reports more than it did: a new Error rule, or an Error that reports more, waits for the next major version. A Warning or an Info may learn more forms in a minor diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index b0f8ff7..b529587 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -419,8 +419,8 @@ They arrive with this package, so whoever can see the interfaces gets the guard: factory. - **CMTK0002**: a type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet` but the project does not reference `CodoMetis.TypeKit.Generators`, so nothing would be generated for it. -- **CMTK0003** (warning): a `Result` or `Option` that a call returns, dropped. `_ = …` says it is - meant. +- **CMTK0003**: a `Result` or `Option` that a call returns, dropped, waited for and forgotten, or + walked by a loop that never reads it. `_ = …` says it is meant. - **CMTK0004**: `Materialize`, which rebuilds a value object without its rules, called anywhere but the EF Core satellite. - **CMTK0005** (warning): `new OrderId[n]` and the like, which fill every slot with a default diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs index e09b7e4..632bca9 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs @@ -6,7 +6,7 @@ namespace CodoMetis.TypeKit.Analyzers.Tests; /// /// Every rule links to its own section of the analyzer README, which an IDE opens from the /// diagnostic. A link to a heading that does not exist opens the top of the page, silently, so the -/// anchor is held to the README's headings. +/// anchor is held to the README's headings, and the README's rule table to each rule's severity. /// public sealed class AnalyzerHelpLinkTests { @@ -35,6 +35,29 @@ public void Every_rule_links_to_its_section_of_the_README(string analyzer) } } + /// + /// The README's rule table states each rule's severity, which decides whether a consumer's build + /// fails. A rule raised to Error in a major version and still listed as a Warning tells the + /// reader the build will pass. + /// + [Theory] + [MemberData(nameof(Analyzers))] + public void The_README_states_every_rules_severity(string analyzer) + { + var type = typeof(ForbiddenDefaultInitializationAnalyzer).Assembly.GetTypes().Single(candidate => candidate.Name == analyzer); + var instance = (DiagnosticAnalyzer)Activator.CreateInstance(type)!; + var rows = File.ReadAllLines(Path.Combine(RepositoryRoot(), "src", "CodoMetis.TypeKit.Analyzers", "README.md")) + .Where(line => line.StartsWith("| CMTK", StringComparison.Ordinal)) + .Select(line => line.Split('|', StringSplitOptions.TrimEntries)) + .ToDictionary(cells => cells[1], cells => cells[2], StringComparer.Ordinal); + + foreach (var rule in instance.SupportedDiagnostics) + { + rows.ShouldContainKey(rule.Id, $"The analyzer README's rule table has no row for {rule.Id}."); + rows[rule.Id].ShouldBe(rule.DefaultSeverity.ToString(), $"The analyzer README lists {rule.Id} as {rows[rule.Id]}, and the rule reports at {rule.DefaultSeverity}."); + } + } + private static string RepositoryRoot() { for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs index 153c446..17833d0 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs @@ -103,7 +103,7 @@ public Task CMTK0002_reports_in_markup_only() => [Fact] public Task CMTK0003_reports_in_markup_only() => - Verify("private void Run$(Service service) { {|#0:service.Load()|}; }", "CMTK0003", DiagnosticSeverity.Warning); + Verify("private void Run$(Service service) { {|#0:service.Load()|}; }", "CMTK0003", DiagnosticSeverity.Error); [Fact] public Task CMTK0004_reports_in_markup_only() => diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index 7a5e40d..d86f184 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -5,7 +5,8 @@ namespace CodoMetis.TypeKit.Analyzers.Tests; /// /// CMTK0003: a Result or Option, or a collection of them, dropped by an expression -/// statement or by a conversion of its task to a plain Task. +/// statement, by a conversion of its task to a plain Task, by Wait, or by a loop that +/// never reads them. /// public sealed class IgnoredOutcomeAnalyzerTests { @@ -46,6 +47,13 @@ public sealed class Service public Task>> SaveAllAsync(int[] values) => Task.FromResult>>([]); + public IEnumerable> SaveEach(int[] values) => values.Select(Save); + + public async IAsyncEnumerable> SaveEachAsync(int[] values) + { + foreach (var value in values) yield return await CancelAsync(value); + } + public Task RunAsync() => Task.CompletedTask; public int Count() => 1; @@ -68,7 +76,7 @@ private static AnalyzerTest Test(string code, params Dia } private static DiagnosticResult Cmtk0003(int location, string outcome, string method) => - new DiagnosticResult("CMTK0003", DiagnosticSeverity.Warning).WithLocation(location).WithArguments(outcome, method); + new DiagnosticResult("CMTK0003", DiagnosticSeverity.Error).WithLocation(location).WithArguments(outcome, method); /// A second file, whose own using directives come first. private static AnalyzerTest TestWithFile(string file, params DiagnosticResult[] expected) @@ -84,7 +92,7 @@ public void The_descriptor_is_the_published_contract() var rule = new IgnoredOutcomeAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); rule.Id.ShouldBe("CMTK0003"); - rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Warning); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); rule.IsEnabledByDefault.ShouldBeTrue(); } @@ -97,7 +105,7 @@ public static class Caller public static void Run(Service service) { {|#0:service.Save(1)|}; } } """, - new DiagnosticResult("CMTK0003", DiagnosticSeverity.Warning) + new DiagnosticResult("CMTK0003", DiagnosticSeverity.Error) .WithLocation(0) .WithMessage("The Result that 'Save' returns is ignored, so its outcome is never seen. Handle it, or discard it with '_ =' if nothing depends on it.")) .RunAsync(TestContext.Current.CancellationToken); @@ -417,6 +425,135 @@ public static async Task Run(Service service, int[] ids, List """) .RunAsync(TestContext.Current.CancellationToken); + /// + /// Wait blocks until the result exists and hands it to nobody, whatever reads what it returns; + /// Task.WaitAll over tasks the statement made converts them to plain ones; a lazy sequence a + /// call made without being given a result makes its results as it is materialised. + /// + [Fact] + public Task Waiting_for_or_materialising_results_the_statement_made_reports() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, int[] ids) + { + {|#0:service.CancelAsync(1).Wait()|}; + if ({|#1:service.FindAsync().Wait(100)|}) { } + {|#2:Task.WhenAll(service.CancelAsync(1), service.CancelAsync(2)).Wait()|}; + Task.WaitAll({|#3:ids.Select(service.CancelAsync)|}); + Task.WaitAll({|#4:ids.Select(i => service.CancelAsync(i)).ToArray()|}); + {|#5:service.SaveEach(ids).ToList()|}; + {|#6:await service.SaveEachAsync(ids).ToListAsync()|}; + Task.WaitAny({|#7:ids.Select(service.CancelAsync).ToArray()|}); + Drain({|#8:ids.Select(service.CancelAsync).ToArray()|}); + } + + private static void Drain(ReadOnlySpan tasks) { } + } + """, + Cmtk0003(0, "Result", "CancelAsync"), + Cmtk0003(1, "Option", "FindAsync"), + Cmtk0003(2, "collection of Results", "WhenAll"), + Cmtk0003(3, "collection of Results", "Select"), + Cmtk0003(4, "collection of Results", "ToArray"), + Cmtk0003(5, "collection of Results", "ToList"), + Cmtk0003(6, "collection of Results", "ToListAsync"), + Cmtk0003(7, "collection of Results", "ToArray"), + Cmtk0003(8, "collection of Results", "ToArray")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A task still held keeps its result after Wait, and so do held tasks after + /// Task.WaitAll; a plain task has none; a held sequence, or one filtered from what the + /// statement was given, made nothing; an explicit cast to plain tasks says the drop is intended. + /// + [Fact] + public Task Waiting_for_or_materialising_results_that_are_held_stays_silent() => + Test( + """ + public static class Caller + { + public static void Run(Service service, int[] ids, Task> held, List>> pending, IEnumerable> each) + { + var task = service.CancelAsync(1); + task.Wait(); + held.Wait(); + service.RunAsync().Wait(); + Task.WaitAll(pending); + Task.WaitAll(pending.ToArray()); + Task.WaitAll(task, held); + Task.WaitAll(ids.Select(_ => service.RunAsync())); + each.ToList(); + each.Where(result => result.TryGetError(out _)).ToList(); + _ = service.SaveEach(ids).ToList(); + var all = service.SaveEach(ids).ToList(); + Task.WaitAll((ReadOnlySpan)ids.Select(service.CancelAsync).ToArray()); + Task.WaitAll((IEnumerable)ids.Select(service.CancelAsync)); + } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A loop over results its header made, whose body never reads the iteration variable, drains + /// them: a call, a method group passed to Select, an asynchronous stream with its + /// cancellation, a sequence of tasks. + /// + [Fact] + public Task A_loop_that_never_reads_the_results_its_header_made_reports() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, int[] ids, System.Threading.CancellationToken token) + { + var count = 0; + foreach (var outcome in {|#0:service.SaveAll(ids)|}) { } + foreach (var outcome in {|#1:ids.Select(service.Save)|}) count++; + await foreach (var outcome in {|#2:service.SaveEachAsync(ids)|}) { } + await foreach (var outcome in {|#3:service.SaveEachAsync(ids).WithCancellation(token)|}) count++; + foreach (var pending in {|#4:ids.Select(service.CancelAsync)|}) { } + } + } + """, + Cmtk0003(0, "sequence of Results", "SaveAll"), + Cmtk0003(1, "sequence of Results", "Select"), + Cmtk0003(2, "sequence of Results", "SaveEachAsync"), + Cmtk0003(3, "sequence of Results", "WithCancellation"), + Cmtk0003(4, "sequence of Results", "Select")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A loop that reads each result, even only inside a lambda, handles them; a variable named + /// _ says the drop is intended; a held collection keeps its results; a loop over anything + /// else is not about results. + /// + [Fact] + public Task A_loop_that_reads_its_results_discards_them_or_walks_a_held_collection_stays_silent() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, int[] ids, List> loaded, IAsyncEnumerable> stream) + { + var errors = 0; + foreach (var outcome in service.SaveAll(ids)) if (outcome.TryGetError(out _)) errors++; + await foreach (var outcome in service.SaveEachAsync(ids)) _ = outcome; + foreach (var outcome in service.SaveEach(ids)) Run(() => outcome.TryGetError(out _)); + foreach (var _ in service.SaveAll(ids)) { } + await foreach (var _ in service.SaveEachAsync(ids)) { } + foreach (var outcome in loaded) { } + await foreach (var outcome in stream) { } + foreach (var outcome in loaded.Where(result => result.TryGetValue(out _, out _))) { } + foreach (var count in service.Counts()) { } + } + + private static void Run(Func check) { } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + /// /// A generated TryFrom on a value object of the same project does not bind where Metalama /// runs analyzers; CS0117 here reproduces that compilation. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 94e5b43..9cd5abd 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -1218,12 +1218,12 @@ await File.WriteAllTextAsync(Path.Combine(_directory, "Consumer.csproj"), await File.WriteAllTextAsync(Path.Combine(_directory, "OtherFile.cs"), OtherFile); // Only errors are read, so the rules that ship as warnings or a suggestion are raised here. + // CMTK0003 ships as an error, and is left to its own severity, so its reports here prove it. await File.WriteAllTextAsync(Path.Combine(_directory, ".editorconfig"), """ root = true [*.cs] - dotnet_diagnostic.CMTK0003.severity = error dotnet_diagnostic.CMTK0005.severity = error dotnet_diagnostic.CMTK0006.severity = error dotnet_diagnostic.CMTK0007.severity = error diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs index 1c58858..063b92f 100644 --- a/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/RuleSamples.cs @@ -24,6 +24,10 @@ public interface IOrders Task> CancelAsync(OrderId id); Result Cancel(OrderId id); + + IEnumerable> CancelEach(IEnumerable ids); + + IAsyncEnumerable> CancelEachAsync(IEnumerable ids); } public interface IOrderIds @@ -103,6 +107,21 @@ public static async Task DroppedCollections(IOrders orders, IEnumerable // end sample } + /// The silent part is compiled outside the suppression, so a report on it fails the build. + public static async Task DroppedByWaitingOrLooping(IOrders orders, IEnumerable ids, OrderId id) + { +#pragma warning disable CMTK0003 // what the sample shows the rule reporting + // sample: CodoMetis.TypeKit.Analyzers/cmtk0003-waits + orders.CancelAsync(id).Wait(); // CMTK0003: blocks until the Result exists, then forgets it + Task.WaitAll(ids.Select(orders.CancelAsync)); // CMTK0003: the tasks become plain ones + await foreach (var outcome in orders.CancelEachAsync(ids)) { } // CMTK0003: every Result, read by nobody + // end sample +#pragma warning restore CMTK0003 + // sample: CodoMetis.TypeKit.Analyzers/cmtk0003-waits + await foreach (var _ in orders.CancelEachAsync(ids)) { } // a variable named _ says the drop is meant + // end sample + } + public static void DefaultFilledArray(int count, IEnumerable rows) { #pragma warning disable CMTK0005 // what the sample shows the rule reporting From 3457c2f8cf1b34f2339c16bc6f08dece8fd6421f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 18:32:48 +0200 Subject: [PATCH 028/147] fix: Catch refuses a task in every form, the command form's value included A review found the async refusal only on the untyped overloads. Measured in a consumer compilation, these compiled and answered wrongly: - Catch(() => FetchAsync()), a method group, and an async lambda: bound to the command form's Action, the task discarded, an async lambda as async void, whose exception reaches no caller; - Catch, TException>(...) and Catch>(...): a success holding the task; - Catch(() => FetchValueAsync()) and Catch(() => stream .ReadAsync(buffer)): a ValueTask held, or discarded. Catch(Func) is a compile error naming CatchAsync, and so is Catch(Func): any value the command form would discard, which is the only overload a ValueTask can reach. The spike's finding holds: ValueTask refusal overloads beside the Task ones made every async lambda CS0121, measured again. A task as the value, spelled or inferred, is refused at run time before the delegate runs, as is a task whose value is a task in CatchAsync; the message links to the README's new "A task given to Catch". Refusals over two type arguments were measured and dropped: they make a lambda that only throws, or returns null!, ambiguous, and what they would refuse is already a conversion error. The command form no longer discards a value: Catch(() => int.Parse(s)) is a compile error naming Catch; a statement lambda is a command. Catch is new in 2.0.0, so no shipped call changes. Seeds: each refusal overload, each of the four guards, and the guard's ValueTask branch, each failing its test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- design/plan.md | 12 +++ src/CodoMetis.TypeKit/README.md | 28 ++++- src/CodoMetis.TypeKit/Refusals.cs | 3 + src/CodoMetis.TypeKit/ResultExtensions.cs | 66 +++++++++++- .../RefusalLinkTests.cs | 5 + .../ResultCatchTests.cs | 102 ++++++++++++++++-- 7 files changed, 204 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a98e2..276b355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,11 @@ this release adds, so nothing that built against 1.5.1 meets them. task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through every overload, unless it is a timeout (one over a `TimeoutException`, as `HttpClient` throws when its `Timeout` elapses), which is the error; a delegate that returns a - `Task` given to `Catch` is a compile error that names `CatchAsync`, and a delegate that returns - null throws as `Result.Success(null)` does. + `Task`, an `async` lambda among them, given to `Catch` or to its command form `Catch` + is a compile error that names `CatchAsync`, and so is a delegate that returns any value given to the + command form, which would discard it; a `ValueTask`, or a task spelled as the type argument, is + refused before the delegate runs; and a delegate that returns null throws as `Result.Success(null)` + does. - **The closed-set family**, the release's headline: `IClosedSet` in `CodoMetis.TypeKit`, generated by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static readonly` field, with constant data through a private constructor where the members carry some. The diff --git a/design/plan.md b/design/plan.md index 3087011..e559d0f 100644 --- a/design/plan.md +++ b/design/plan.md @@ -633,6 +633,18 @@ Each phase ends green, and its guards have been proven by seeding the defect what it adds. The 1.5.1 baseline, run over all seven packages with the attribute and parameter-name rules on as well, reports the one suppressed difference and compiler-emitted attributes only. + 9a. **A review's findings** (2026-10-13), each a commit: + - **`Catch` over a task** ✅: the command form `Catch(Action)` took a delegate + returning a task and discarded it, an `async` lambda as `async void`; a task spelled as the + type argument (`Catch, …>`) and an inferred `ValueTask` were a success holding the + task. `Catch(Func)` is refused at compile time, and so is + `Catch(Func)`, any value the command form would discard, which is the + only overload a `ValueTask` reaches (the spike's ambiguity rules out `ValueTask` + overloads beside `Task` ones: measured again, every `async` lambda became CS0121). A task + as the value, spelled or inferred, is refused at run time before the delegate runs, and in + `CatchAsync` a task whose value is a task. Two-type-argument refusals were measured and + dropped: they make a lambda that only throws, or returns `null!`, ambiguous, and the calls + they would refuse are already the compiler's conversion errors. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index b529587..3d5c268 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -181,15 +181,18 @@ Result fetched = await Result.CatchAsync(() => client.GetByte `Catch` catches that type only and lets every other exception propagate, so the error type says what was expected to fail; both type arguments are spelled, since C# infers all or none, -and `Catch(() => int.Parse(s))` with one is the command form, which discards the -number. `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error +and `Catch(() => { … })` with one is the command form, for a call without a +value: given one that returns a value, which it would discard, it is a compile error naming the +valued form. `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through all of them: a cancellation is not an outcome, and caught it would read as a failure that `Recover` then runs on. A timeout is the exception: one over a `TimeoutException` is how `HttpClient` reports its `Timeout` elapsing, which is the call failing, so it is the error, and a narrowed `Catch` names `TaskCanceledException` or -`OperationCanceledException` to catch it alone. A delegate that returns a `Task` -given to `Catch` is a compile error, since the task's exception would be thrown after `Catch` had -returned; a `ValueTask` needs `.AsTask()` or an `async` lambda, as above. A delegate that returns +`OperationCanceledException` to catch it alone. A delegate that returns a `Task`, an `async` lambda +among them, given to `Catch` or its command form is a compile error naming `CatchAsync`, since the +task's exception would be thrown after `Catch` had returned. A `ValueTask`, or a task spelled as the +type argument (`Catch, …>`), is refused when called, before the delegate runs; `CatchAsync` +takes a `ValueTask` through `.AsTask()` or an `async` lambda, as above. A delegate that returns null throws, as `Result.Success(null)` does. `Map`, `Bind` and the rest are unchanged: a selector that throws propagates. An exception is for the code that called, not for a client: `ToHttpResult()` does not accept a `Result`, and `MapError` turns the exception into a fault that it does. @@ -576,6 +579,21 @@ surfaces as a `TypeInitializationException`, and the set cannot be used for the instance, keep data a member needs in its own constructor's arguments, and read a name in a property or method rather than a static field initializer. +### A task given to Catch + +**Thrown:** `ArgumentException: Catch was given a delegate whose value is a task…` from `Catch` when +its value's type is a `Task` or a `ValueTask`, spelled (`Catch, HttpRequestException>`) or +inferred from a delegate that returns a `ValueTask`; and from `CatchAsync` when the task's value is +another task. Nothing has run: the delegate is refused before it is called. + +**Why:** the task's exception is thrown after `Catch` has returned, so the answer would be a success +whatever the call does. A delegate that returns a `Task` is refused at compile time; a `ValueTask` +converts to no task type an overload could name without making every `async` lambda ambiguous, and a +spelled type argument chooses its overload itself. + +**Instead:** `CatchAsync`, which awaits the task: `await Result.CatchAsync(() => stream.ReadAsync(buffer).AsTask())`, +or an `async` lambda. In `CatchAsync`, await the inner task in the delegate. + ### Materialize and CMTK0004 Not a run-time exception but a build error: `Materialize` rebuilds a value object without `Create`, diff --git a/src/CodoMetis.TypeKit/Refusals.cs b/src/CodoMetis.TypeKit/Refusals.cs index dcc83be..5fe5ebe 100644 --- a/src/CodoMetis.TypeKit/Refusals.cs +++ b/src/CodoMetis.TypeKit/Refusals.cs @@ -39,6 +39,9 @@ internal static class Refusals /// A closed set asked for a member it does not have, by name, in text or JSON. public const string ClosedSetMember = " See " + Readme + "a-closed-set-has-no-member-of-that-name"; + /// A task given to Catch as its value, or to CatchAsync as the value of its task. + public const string TaskGivenToCatch = " See " + Readme + "a-task-given-to-catch"; + /// A closed set's instance that is not one of its members, asked for its name. public const string ClosedSetNotAMember = " See " + Readme + "an-instance-that-is-not-a-member-of-its-set"; } diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 2859c8a..7d4717d 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -54,9 +54,11 @@ public static Error Error(T error) where T : notnull => /// The type of the value. /// A success holding the value, or an error holding the exception thrown. /// is null, or returned null. + /// is a task, a Task or a ValueTask; use CatchAsync. public static Result Catch(Func action) where T : notnull { ArgumentNullException.ThrowIfNull(action); + RefuseTask(nameof(Catch)); T value; try @@ -111,11 +113,13 @@ public static Result Catch(Action action) /// The exception that is an outcome; any other propagates. /// A success holding the value, or an error holding the exception thrown. /// is null, or returned null. + /// is a task, a Task or a ValueTask; use CatchAsync. public static Result Catch(Func action) where T : notnull where TException : Exception { ArgumentNullException.ThrowIfNull(action); + RefuseTask(nameof(Catch)); T value; try @@ -135,10 +139,11 @@ public static Result Catch(Func action) /// it threw as the error. Any other exception propagates. /// /// - /// A lambda with an expression body converts to an and its value is - /// discarded, so Result.Catch<FormatException>(() => int.Parse(s)) is this overload - /// and answers a without the number; the valued form spells both - /// type arguments. See for cancellation and tasks. + /// A delegate that returns a value is refused at compile time, a task's included, since this + /// overload would discard it: Result.Catch<int, FormatException>(() => int.Parse(s)) + /// spells both type arguments, and CatchAsync waits for a task. A statement lambda that + /// drops a value says so, () => { _ = list.Remove(item); }. See + /// for cancellation. /// /// The call whose outcome is wanted. /// The exception that is an outcome; any other propagates. @@ -190,6 +195,55 @@ private static bool IsCancellation(Exception exception) => [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public static Result Catch(Func action) => throw new NotSupportedException(); + /// Refused: the task's exception is thrown after Catch has returned. Use . + /// + /// Without it, a delegate returning a task, an async lambda among them, converts to the + /// command form's : the task is discarded, and an async lambda becomes + /// async void, whose exception reaches no caller at all. + /// + /// A delegate returning a task. + /// The exception that would be an outcome. + /// Never: the overload does not compile. + [Obsolete("The delegate returns a Task, whose exception is thrown after Catch has returned. Use CatchAsync.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static Result Catch(Func action) where TException : Exception => throw new NotSupportedException(); + + /// + /// Refused: the command form would discard the value, and with a ValueTask its exception. + /// Use , or for a task. + /// + /// + /// A lambda with an expression body converts to and its value is dropped, so + /// Catch<FormatException>(() => int.Parse(s)) answered without the number, and + /// Catch<IOException>(() => stream.ReadAsync(buffer)) without the read's exception: a + /// ValueTask<T> converts to no task type an overload could name. Every value converts to + /// , so this overload is the better match for any of them; a task is + /// 's, the more specific one. + /// + /// A delegate returning a value. + /// The exception that would be an outcome. + /// Never: the overload does not compile. + [Obsolete("The delegate returns a value, which Catch would discard, a ValueTask's exception with it. Spell the value's type, Catch, or use CatchAsync for a task.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static Result Catch(Func action) where TException : Exception => throw new NotSupportedException(); + + /// + /// Refuses a value that is itself a task, before the delegate runs: its exception would be thrown + /// after Catch had returned a success. The overloads over a task-returning delegate refuse + /// it at compile time; this is the type argument spelled as a task, Catch<Task<int>, …>, + /// and a ValueTask, which no overload can name beside Task without making every + /// async lambda ambiguous. + /// + private static void RefuseTask(string method) + { + var type = typeof(T); + if (typeof(Task).IsAssignableFrom(type) || type == typeof(ValueTask) || type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)) + throw new ArgumentException( + $"{method} was given a delegate whose value is a task, whose exception would be thrown after {method} had returned a success. " + + (method == nameof(Catch) ? "Use CatchAsync." : "Await the inner task in the delegate.") + Refusals.TaskGivenToCatch, + "action"); + } + /// /// Awaits and returns its value as a success, or the exception the /// task threw as the error, whether before or after its first await. @@ -206,9 +260,11 @@ private static bool IsCancellation(Exception exception) => /// The type of the value. /// A success holding the value, or an error holding the exception thrown. /// is null, or its task produced null. + /// is itself a task; await it in the delegate. public static async Task> CatchAsync(Func> action) where T : notnull { ArgumentNullException.ThrowIfNull(action); + RefuseTask(nameof(CatchAsync)); T value; try @@ -256,11 +312,13 @@ public static async Task> CatchAsync(Func action) /// The exception that is an outcome; any other propagates. /// A success holding the value, or an error holding the exception thrown. /// is null, or its task produced null. + /// is itself a task; await it in the delegate. public static async Task> CatchAsync(Func> action) where T : notnull where TException : Exception { ArgumentNullException.ThrowIfNull(action); + RefuseTask(nameof(CatchAsync)); T value; try diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index a99d185..d64a626 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -43,6 +43,7 @@ public sealed partial class RefusalLinkTests private const string NullToValueObject = "A null given to a value object"; private const string ClosedSetMember = "A closed set has no member of that name"; private const string ClosedSetNotAMember = "An instance that is not a member of its set"; + private const string TaskGivenToCatch = "A task given to Catch"; private enum Fault { TooLong } @@ -94,6 +95,10 @@ private enum Fault { TooLong } ["unreadable text, static Parse"] = (Unreadable, () => GeneratedParsing.Guarded("x", static text => int.Parse(text, CultureInfo.InvariantCulture))), ["unreadable text, type converter"] = (Unreadable, () => GeneratedParsing.ConvertFromString("x", null)), + ["Catch over a spelled task"] = (TaskGivenToCatch, () => Result.Catch, InvalidOperationException>(() => Task.FromResult(1))), + ["Catch over a ValueTask"] = (TaskGivenToCatch, () => Result.Catch(() => ValueTask.CompletedTask)), + ["CatchAsync over a task of a task"] = (TaskGivenToCatch, () => Result.CatchAsync(() => Task.FromResult(Task.FromResult(1))).GetAwaiter().GetResult()), + ["From(null)"] = (NullToValueObject, () => throw GeneratedFactories.NullRefused("value")), ["a JSON null"] = (NullToValueObject, () => throw GeneratedJson.NullToken()), }; diff --git a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs index 71427cf..0d3a4f4 100644 --- a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs @@ -63,15 +63,54 @@ public void A_narrowed_Catch_catches_that_type_and_lets_any_other_propagate() /// /// A lambda with an expression body converts to , so the one-type-argument - /// form over a value-returning call is the value-less overload, and the value is discarded. The - /// valued form spells both type arguments; the README says so. + /// form over a value-returning call would discard the value, and a ValueTask's exception with + /// it. That is a compile error naming the valued form; a statement lambda is a command. /// - [Fact] - public void A_one_type_argument_Catch_over_a_value_is_the_value_less_overload() + [Theory] + [InlineData("Result.Catch(() => int.Parse(text))", "CS0619")] + [InlineData("Result.Catch(() => Items.Remove(text))", "CS0619")] + [InlineData("Result.Catch(() => { Items.Remove(text); })", "")] + [InlineData("Result.Catch(() => Items.Clear())", "")] + [InlineData("Result.Catch(() => int.Parse(text))", "")] + public void A_one_type_argument_Catch_over_a_value_is_a_compile_error_naming_the_valued_form(string call, string error) { - var bound = Result.Catch(() => int.Parse("42")); + var compilation = ConsumerCompilation.Of($$""" + using System; + using System.Collections.Generic; + using CodoMetis.TypeKit; + + static class Consumer + { + static readonly List Items = []; + static object Call(string text) => {{call}}; + } + """); - bound.ShouldBeOfType>(); + compilation.ErrorIds().ShouldBe(error.Length == 0 ? [] : [error], compilation.Problems()); + if (error.Length > 0) compilation.Problems().ShouldContain("Catch"); + } + + /// + /// A task's type spelled as the value, or inferred where no overload can name it (a + /// ValueTask), would make the task's exception arrive after a success was returned. It is + /// refused before the delegate runs, and the message names CatchAsync; CatchAsync + /// whose task's value is another task is refused the same way. + /// + [Fact] + public async Task A_task_as_the_value_is_refused_before_the_delegate_runs() + { + var ran = false; + Task Fails() { ran = true; return Task.FromException(Boom); } + + Should.Throw(() => Result.Catch, InvalidOperationException>(Fails)).Message.ShouldContain("Use CatchAsync."); + Should.Throw(() => Result.Catch>(Fails)); + Should.Throw(() => Result.Catch(Fails)); + Should.Throw(() => Result.Catch(() => { ran = true; return new ValueTask(1); })); + Should.Throw(() => Result.Catch(() => { ran = true; return ValueTask.CompletedTask; })); + (await Should.ThrowAsync(() => Result.CatchAsync(() => Task.FromResult(Fails())))).Message.ShouldContain("Await the inner task"); + await Should.ThrowAsync(() => Result.CatchAsync, InvalidOperationException>(() => Task.FromResult(Fails()))); + + ran.ShouldBeFalse(); } [Fact] @@ -217,17 +256,32 @@ public void The_combinators_are_unchanged_so_a_throwing_selector_propagates() /// /// A delegate returning a task given to Catch would bind T to the task and catch - /// nothing it throws. The overload over a task-returning delegate is more specific, so the - /// compiler picks it, and it is an error that names CatchAsync. + /// nothing it throws, and given to the command form Catch<TException> would be + /// discarded, an async lambda as async void. The overloads over a task-returning + /// delegate are more specific, so the compiler picks them, and they are errors that name + /// CatchAsync. A ValueTask has no such overload (it would make every async lambda + /// ambiguous): the command form's refusal of any value covers it, and the valued form refuses it at + /// run time. /// [Theory] [InlineData("Result.Catch(() => FetchAsync())")] [InlineData("Result.Catch(FetchAsync)")] [InlineData("Result.Catch(() => DoAsync())")] [InlineData("Result.Catch(DoAsync)")] + [InlineData("Result.Catch(async () => await FetchAsync())")] + [InlineData("Result.Catch(async () => await DoAsync())")] + [InlineData("Result.Catch(() => FetchAsync())")] + [InlineData("Result.Catch(FetchAsync)")] + [InlineData("Result.Catch(() => DoAsync())")] + [InlineData("Result.Catch(DoAsync)")] + [InlineData("Result.Catch(async () => await DoAsync())")] + [InlineData("Result.Catch(async () => { await DoAsync(); })")] + [InlineData("Result.Catch(() => DoValueAsync())")] + [InlineData("Result.Catch(() => FetchValueAsync())")] public void A_task_returning_delegate_given_to_Catch_is_a_compile_error_naming_CatchAsync(string call) { var compilation = ConsumerCompilation.Of($$""" + using System; using System.Threading.Tasks; using CodoMetis.TypeKit; @@ -235,6 +289,8 @@ static class Consumer { static Task FetchAsync() => Task.FromResult(1); static Task DoAsync() => Task.CompletedTask; + static ValueTask FetchValueAsync() => new(1); + static ValueTask DoValueAsync() => ValueTask.CompletedTask; static object Call() => {{call}}; } """); @@ -243,6 +299,34 @@ static class Consumer compilation.Problems().ShouldContain("CatchAsync"); } + /// + /// The valued form with the value's type spelled cannot take a task at all: the compiler's own + /// conversion error, with no overload to make it compile. A refusal overload there would make a + /// lambda that only throws, or returns null!, ambiguous. + /// + [Theory] + [InlineData("Result.Catch(() => FetchAsync())")] + [InlineData("Result.Catch(FetchAsync)")] + [InlineData("Result.Catch(async () => await FetchAsync())")] + [InlineData("Result.Catch(() => FetchValueAsync())")] + public void A_task_given_to_the_valued_Catch_with_its_value_type_spelled_does_not_compile(string call) + { + var compilation = ConsumerCompilation.Of($$""" + using System; + using System.Threading.Tasks; + using CodoMetis.TypeKit; + + static class Consumer + { + static Task FetchAsync() => Task.FromResult(1); + static ValueTask FetchValueAsync() => new(1); + static object Call() => {{call}}; + } + """); + + compilation.ErrorIds().ShouldNotBeEmpty(compilation.Problems()); + } + /// /// The cost of the trap: a lambda that only throws has no return type. With a type argument it is /// ambiguous between Func<T> and the trap's Func<Task<T>>; without @@ -254,6 +338,8 @@ static class Consumer [InlineData("Result.Catch(int () => throw new Exception())", "")] [InlineData("Result.Catch(() => throw new Exception())", "CS0619")] [InlineData("Result.Catch(void () => throw new Exception())", "")] + [InlineData("Result.Catch(() => throw new Exception())", "CS0121")] + [InlineData("Result.Catch(void () => throw new Exception())", "")] public void A_lambda_that_only_throws_states_its_return_type(string call, string error) { var compilation = ConsumerCompilation.Of($$""" From a99de5588f3a0f9dcf28aa4a5e4c886a20f6df4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 18:39:50 +0200 Subject: [PATCH 029/147] fix: a patch's schema admits null beside every value its argument admits A review found patch schemas a validator reads wrongly. Measured in the probe document, 3.1 and 3.0: - a string enum's patch: no type of its own, so admitting null wrote "type": "null" beside its names, and the schema admitted nothing; - a closed set's patch: "type": ["null", "string"] with an enum that lists no null, so null was refused; - Patch: "type": "null", so it admitted only null. Null now joins what restricts the values: an enum's list gains null, a type gains null (3.0's nullable), and a schema that combines subschemas is kept whole in a branch beside a null one. A schema that restricts nothing stays so. ASP.NET's own polymorphic schema needs no branch, its derived schemas stating no type; a host's oneOf of typed schemas does. The test validates values against the document as a client would, with JsonSchema.Net 9.4.0 (test-only), the property's references inlined and 3.0's nullable read as 3.0.3 defines it: for eleven kinds of argument, a patch accepts null and judges every other value exactly as the argument's own schema does. Seeds: the original code, the enum's null, the branch and the typed path, each failing the test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- Directory.Packages.props | 2 + design/plan.md | 8 + docs/merge-patches.md | 4 +- .../PatchSchemaTransformer.cs | 38 ++++- src/CodoMetis.TypeKit.AspNetCore/README.md | 3 +- .../CodoMetis.TypeKit.AspNetCore.Tests.csproj | 1 + .../PatchSchemaTests.cs | 145 +++++++++++++++++- 8 files changed, 200 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 276b355..23767d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,10 @@ this release adds, so nothing that built against 1.5.1 meets them. constructor, since a converter built by reflection has no code there. - **`CodoMetis.TypeKit.AspNetCore`**: `AddTypeKit()` describes a `Patch` property as `T`'s schema, inlined, not required and admitting null, in 3.1 and 3.0; a value object inside a patch through the - type it wraps. + type it wraps. Null joins what restricts the values, so a validator accepts it beside every value + the argument accepts: an enum's list, where a string enum's patch had admitted nothing and a closed + set's had refused null, the type, or a branch beside the argument's own; an `object`'s schema stays + unrestricted, where it had admitted only null. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0010, the constraints a type parameter cannot declare. `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` (new in the base package) on a type parameter say what C# has no `where` for, and the rule reports a type argument that breaks it at any closed diff --git a/Directory.Packages.props b/Directory.Packages.props index 65a5ba6..dc9212f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -60,6 +60,8 @@ + + diff --git a/design/plan.md b/design/plan.md index e559d0f..f9689ca 100644 --- a/design/plan.md +++ b/design/plan.md @@ -645,6 +645,14 @@ Each phase ends green, and its guards have been proven by seeding the defect `CatchAsync` a task whose value is a task. Two-type-argument refusals were measured and dropped: they make a lambda that only throws, or returns `null!`, ambiguous, and the calls they would refuse are already the compiler's conversion errors. + - **A patch's schema refused or admitted the wrong values** ✅, measured by validating values + with JsonSchema.Net (test-only) against the document, 3.0's `nullable` read as 3.0.3 defines + it: a string enum's patch was `"type": "null"` beside its names and admitted nothing, a + closed set's listed no null in its enum and refused it, and `Patch` was + `"type": "null"` and admitted only null. Null now joins what restricts the values: the + enum's list, the type, or, for a schema combining subschemas, a branch beside them. + ASP.NET's own polymorphic schema needed no branch, its derived schemas stating no type; a + host's `oneOf` of typed schemas does, and guards it. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the diff --git a/docs/merge-patches.md b/docs/merge-patches.md index 5d045ed..c4f4186 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -130,7 +130,9 @@ app.MapPatch("/contacts/{id}", (int id, ContactPatch patch) => TypedResults.NoCo The body binds as any other: each property of `ContactPatch` is read by its patch, and one the request leaves out is `Absent`. With `AddTypeKit()`, the document describes a patch property as its argument's schema, inlined rather than as a component of its own, admitting `null`, and never -required, in OpenAPI 3.1 and 3.0. A value object inside a patch is described through the type it +required, in OpenAPI 3.1 and 3.0. Null joins whatever restricts the values: the type, an enum's list +(a closed set's names, an enum written as strings), or a branch beside the argument's own branches, +so a validator accepts null and judges every other value as it judges the argument's. A value object inside a patch is described through the type it wraps, so `email` is a `string`. This is not ASP.NET Core's JSON Patch (RFC 6902, `application/json-patch+json`), which is a list of operations. diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index 20d55cd..5b4d6b8 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -33,6 +33,41 @@ public static void Apply(OpenApiOptions options) private static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Patch<>); + /// + /// The argument's schema, as it is, with null admitted beside every value it admits. + /// + /// + /// + /// How depends on what restricts the values. A type gains null, which 3.0 writes as + /// nullable. An enum lists every value there is, so it gains null as a value too: + /// without it a closed set's "type": ["null", "string"] still refused null, and a string + /// enum, which has no type, became "type": "null" beside its names and admitted nothing. + /// A subschema refuses null whatever the type says, so a schema that combines subschemas, such as + /// a host's oneOf of a string and a number, is kept whole in a branch beside a null one. + /// (ASP.NET's own polymorphic schema does not need it: its branches state no type.) A schema that + /// restricts nothing, an 's, admits null as it is; it became + /// "type": "null" and admitted only null. Every other keyword (required, + /// pattern, items) applies to its own type only and lets null through. + /// + /// + private static void AdmitNull(OpenApiSchema schema, OpenApiSchema inner) + { + if (inner.AnyOf is { Count: > 0 } || inner.OneOf is { Count: > 0 } || inner.AllOf is { Count: > 0 } || inner.Not is not null || inner.Const is not null) + { + var value = new OpenApiSchema(); + value.FillFrom(inner); + schema.AnyOf = [value, new OpenApiSchema { Type = JsonSchemaType.Null }]; + return; + } + + schema.FillFrom(inner); + + if (schema.Enum is { Count: > 0 } values && !values.Any(value => value is null || value.GetValueKind() == System.Text.Json.JsonValueKind.Null)) + values.Add(null!); + + if (schema.Type is { } type) schema.Type = type | JsonSchemaType.Null; + } + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { var info = context.JsonTypeInfo; @@ -53,8 +88,7 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC var inner = await context.GetOrCreateSchemaAsync(described, parameterDescription: null, cancellationToken); schema.Properties?.Clear(); - schema.FillFrom(inner); - schema.Type = (schema.Type ?? JsonSchemaType.Null) | JsonSchemaType.Null; + AdmitNull(schema, inner); schema.Description = "A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it." + (inner.Description is null ? "" : " " + inner.Description); } diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 4d7d9d1..db6ea54 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -110,7 +110,8 @@ no component at all), and both parameters are a bare `string`. **A merge patch.** A property typed `Patch` (CodoMetis.TypeKit, a JSON Merge Patch field) is described as `T`'s schema, inlined where the property is rather than as a `PatchOfstring` component, -never required, since absent is a state, and admitting null, since null is the clear. A value object +never required, since absent is a state, and admitting null, since null is the clear: beside the +type, in an enum's list of values, or as a branch beside the argument's own. A value object inside a patch is described through the type it wraps, which is also what a source-generated host can do: the patch's converter hides the value object from its context. diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj b/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj index 535bd40..75a4479 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/CodoMetis.TypeKit.AspNetCore.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 8612802..5ffef39 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -1,5 +1,8 @@ +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using CodoMetis.TypeKit.Generators.Probes; +using Json.Schema; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; @@ -19,15 +22,155 @@ public sealed record Lines(int Count); public sealed record Control(string? Name, int Quantity, ProbeCode Code, ProbeId Id, Lines? Lines); + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum Colour { Red, Green } + + public enum Size { Small, Large } + + [JsonPolymorphic] + [JsonDerivedType(typeof(Circle), "circle")] + [JsonDerivedType(typeof(Square), "square")] + public abstract record Shape; + + public sealed record Circle(int Radius) : Shape; + + public sealed record Square(int Side) : Shape; + + /// Described by the host as a string or a number, two branches that each refuse null. + public sealed record Either(string Value); + + /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all. + public sealed record KindsPatch( + Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines, + Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either); + + /// The same arguments, unpatched: what each value is judged against. + public sealed record Kinds( + string Name, int Quantity, ProbeCode Code, ProbeId Id, Lines Lines, + Colour Colour, ProbeStatus Status, Size Size, int[] Numbers, Shape Shape, object Anything, Either Either); + public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; private static Task DocumentAsync(OpenApiSpecVersion version) => - ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, endpoints: app => + ProbeHost.DocumentAsync(openApi => + { + openApi.AddTypeKit(); + openApi.AddSchemaTransformer((schema, context, _) => + { + if (context.JsonTypeInfo.Type != typeof(Either)) return Task.CompletedTask; + schema.Type = null; + schema.Properties?.Clear(); + schema.Required?.Clear(); + schema.OneOf = [new OpenApiSchema { Type = JsonSchemaType.String }, new OpenApiSchema { Type = JsonSchemaType.Integer }]; + return Task.CompletedTask; + }); + }, version: version, endpoints: app => { app.MapPatch("/patched/{id}", int (int id, OrderPatch patch) => 0); app.MapPost("/control", int (Control control) => 0); + app.MapPatch("/kinds", int (KindsPatch patch) => 0); + app.MapPost("/kinds", int (Kinds kinds) => 0); }); + /// For each property, values its argument accepts and values it refuses, as JSON. + private static readonly (string Property, string[] Values)[] Values = + [ + ("name", ["\"Ada\"", "1"]), + ("quantity", ["1", "\"many\"", "true"]), + ("code", ["\"ABC\"", "1"]), + ("id", ["\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\"", "1"]), + ("lines", ["{\"count\":1}", "{}", "\"x\""]), + ("colour", ["\"Red\"", "\"Blue\"", "1"]), + ("status", ["\"Open\"", "\"Lost\"", "1"]), + ("size", ["0", "\"x\""]), + ("numbers", ["[1,2]", "[\"x\"]", "1"]), + ("shape", ["{\"$type\":\"circle\",\"radius\":1}", "\"x\"", "1"]), + ("anything", ["1", "\"x\"", "{}", "[]"]), + ("either", ["\"x\"", "1", "true"]), + ]; + + /// + /// A patch's schema is its argument's with null beside it, judged by a JSON Schema validator as a + /// client would: null is accepted, and every other value exactly as the argument's own schema + /// judges it. A string enum has no type, a closed set's enum lists its names, and a schema the + /// host describes as branches that each refuse null admits null only beside them, so null cannot be + /// admitted by a type alone; an restricts nothing, and admitting null must not + /// restrict it to null. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task A_patch_accepts_null_and_otherwise_exactly_what_its_argument_accepts(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + Values.Length.ShouldBe(document.Component(nameof(KindsPatch))["properties"]!.AsObject().Count, "every property of the patch has values to judge"); + + foreach (var (property, values) in Values) + { + var patch = Validator(document, document.Property(nameof(KindsPatch), property), version); + var argument = Validator(document, document.Property(nameof(Kinds), property), version); + + patch("null").ShouldBeTrue($"'{property}' should accept null: {document.Property(nameof(KindsPatch), property).ToJsonString()}"); + argument(values[0]).ShouldBeTrue($"'{property}': the control should accept {values[0]}, or the comparison means nothing"); + + foreach (var value in values) + patch(value).ShouldBe(argument(value), $"'{property}' should judge {value} as its argument does: {document.Property(nameof(KindsPatch), property).ToJsonString()}"); + } + } + + /// + /// A validator for a property's schema, its references to components inlined, and in 3.0 its + /// nullable read as 3.0.3 defines it: null added to a type the same schema states, and + /// nothing where it states none. + /// + private static Func Validator(OpenApiJson document, JsonNode schema, OpenApiSpecVersion version) + { + var inlined = Inline(document, schema.DeepClone()); + if (version == OpenApiSpecVersion.OpenApi3_0) ReadNullable(inlined); + inlined.AsObject()["$schema"] = "https://json-schema.org/draft/2020-12/schema"; + + var validator = JsonSchema.FromText(inlined.ToJsonString()); + return value => + { + using var instance = JsonDocument.Parse(value); + return validator.Evaluate(instance.RootElement).IsValid; + }; + } + + private static JsonNode Inline(OpenApiJson document, JsonNode schema) + { + switch (schema) + { + case JsonObject node when node["$ref"]?.GetValue() is { } reference: + return Inline(document, document.Component(reference.Split('/')[^1]).DeepClone()); + case JsonObject node: + foreach (var (key, child) in node.ToArray()) + if (child is not null && key != "discriminator") node[key] = Inline(document, child.DeepClone()); + return node; + case JsonArray array: + for (var index = 0; index < array.Count; index++) + if (array[index] is { } child) array[index] = Inline(document, child.DeepClone()); + return array; + default: + return schema; + } + } + + private static void ReadNullable(JsonNode schema) + { + switch (schema) + { + case JsonObject node: + if (node["nullable"]?.GetValue() == true && node["type"] is JsonValue type) + node["type"] = new JsonArray(type.GetValue(), "null"); + node.Remove("nullable"); + foreach (var (_, child) in node) if (child is not null) ReadNullable(child); + break; + case JsonArray array: + foreach (var child in array) if (child is not null) ReadNullable(child); + break; + } + } + [Theory] [MemberData(nameof(Versions))] public async Task A_patch_property_is_its_argument_s_schema_inlined_and_admits_null(OpenApiSpecVersion version) From a77bddac3facd31cfc071409d0e932b32cc3f433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 18:41:53 +0200 Subject: [PATCH 030/147] fix: CMTK0010 reports a method group whose type arguments the delegate infers A review found the constraint bypassed by a method group: in Action run = Runner.Run; the type arguments are inferred from the delegate, so there is no generic name to check where it is written and no call to check where it is made. Measured: of an assignment, an argument, a cast, a generic local function and a breaching [Sealed] group, none was reported. Every method reference is now checked as an inferred call is, and reported at the group; one with its arguments written stays the generic name's, reported once at the argument. Seeds: the registration, and a group checked as inferred although written, each failing the test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- design/plan.md | 6 ++- docs/type-constraints.md | 5 ++- src/CodoMetis.TypeKit.Analyzers/README.md | 5 ++- .../TypeArgumentConstraintAnalyzer.cs | 29 ++++++++++--- .../TypeArgumentConstraintAnalyzerTests.cs | 43 +++++++++++++++++++ 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23767d1..06850a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,8 +75,8 @@ this release adds, so nothing that built against 1.5.1 meets them. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0010, the constraints a type parameter cannot declare. `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` (new in the base package) on a type parameter say what C# has no `where` for, and the rule reports a type argument that breaks it at any closed - use: a base type, a member, a parameter, a local, a `typeof`, a call with its arguments written - or inferred. A type parameter forwarded without the attribute is a breach too, so the requirement + use: a base type, a member, a parameter, a local, a `typeof`, a call or a method group with its + arguments written or inferred. A type parameter forwarded without the attribute is a breach too, so the requirement follows the generic chain; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0011, with the package's first code fix. A type marked `[RequireParameterlessConstructor]` (new in the base package, with `ConstructorAccessibility`) diff --git a/design/plan.md b/design/plan.md index f9689ca..04a1cb5 100644 --- a/design/plan.md +++ b/design/plan.md @@ -653,6 +653,10 @@ Each phase ends green, and its guards have been proven by seeding the defect enum's list, the type, or, for a schema combining subschemas, a branch beside them. ASP.NET's own polymorphic schema needed no branch, its derived schemas stating no type; a host's `oneOf` of typed schemas does, and guards it. + - **CMTK0010 over a method group** ✅: `Action run = Runner.Run;` infers the + type arguments from the delegate, with no generic name written and no call, and was + silent. Every method reference is checked as an inferred call is, at the group; a group + with its arguments written stays the generic name's, reported once. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the @@ -1751,7 +1755,7 @@ exist. | CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | -| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within decision 34 | +| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within decision 34 | | CMTK0011 | A concrete type implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a type declaring no constructor satisfies `Public`. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | diff --git a/docs/type-constraints.md b/docs/type-constraints.md index 9b15411..e27388f 100644 --- a/docs/type-constraints.md +++ b/docs/type-constraints.md @@ -36,8 +36,9 @@ public sealed class Ledger : Aggregate; // CMTK0010: ``` The rule reports at every closed use of the generic: a base type as above, an implemented interface, -a field, a property, a parameter, a local, a `typeof`, and a call, whether its type arguments are -written or inferred. A written argument is reported where it is written; an inferred one at the call. +a field, a property, a parameter, a local, a `typeof`, a call, and a method group converted to a +delegate, whether its type arguments are written or inferred. A written argument is reported where it +is written; an inferred one at the call, or at the method group, whose arguments come from the delegate. ```csharp public static class Snapshots diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 3535ecb..832317a 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -21,7 +21,7 @@ appears to: an instance that passed no factory, or a value object that was never | CMTK0007 | Info | `FromKnownGood` given a value that comes straight from a parameter. | | CMTK0008 | Warning | The wrapped values of two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves through `Equals(object)`. | | CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind. | -| CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call, written or inferred. A type parameter forwarded without the attribute is a breach too. | +| CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call or a method group, written or inferred. A type parameter forwarded without the attribute is a breach too. | | CMTK0011 | Error | A concrete type that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. The code fix writes or changes the constructor. | | CMTK0012 | Error | A closed set's `Match` given an argument by position, which binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | @@ -340,7 +340,8 @@ C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : a or `record`. A generic whose argument must be an abstract record, the command family of an aggregate, gets no help from the compiler, and the closed type that breaks it compiles. Four attributes on a type parameter say what C# cannot, and this rule reports the use that breaks it, -at the argument where one is written and at the call where the arguments are inferred: +at the argument where one is written, and where the arguments are inferred at the call, or at the +method group that takes them from the delegate it converts to (`Action run = Runner.Run;`): ```csharp public abstract class Decider<[Abstract] TCommand, [Record] TEvent> { } diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index bccdc09..8986d2b 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -18,7 +18,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// The constraint is declared where C# cannot express it (where T : abstract), and the /// violation is at the use site, which may be in any project, so this is an analyzer and not an /// aspect. Every generic name written in source is checked where it is written, at the argument; -/// a call whose type arguments are inferred has no such name and is checked at the call. +/// a call whose type arguments are inferred has no such name and is checked at the call, and a +/// method group whose type arguments are inferred from the delegate it converts to +/// (Action<PlaceOrder> run = Runner.Run;) at the group. /// /// /// A type parameter given as the argument satisfies a constraint only when it carries the same @@ -56,6 +58,7 @@ public override void Initialize(AnalysisContext context) start.RegisterSyntaxNodeAction(node => AnalyzeGenericName(node, symbols), SyntaxKind.GenericName); start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols), OperationKind.Invocation); + start.RegisterOperationAction(operation => AnalyzeMethodGroup(operation, symbols), OperationKind.MethodReference); }); } @@ -88,12 +91,28 @@ private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKi private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols) { var invocation = (IInvocationOperation)context.Operation; - var method = invocation.TargetMethod; + var expression = invocation.Syntax is InvocationExpressionSyntax call ? call.Expression : null; + + AnalyzeInferred(context, symbols, invocation.TargetMethod, expression, invocation.Syntax); + } + + /// + /// A method group whose type arguments are inferred from the delegate it converts to, as an + /// assignment, an argument or a cast: nothing is written, so it is reported at the group. + /// + private static void AnalyzeMethodGroup(OperationAnalysisContext context, TypeKitSymbols symbols) + { + var reference = (IMethodReferenceOperation)context.Operation; + + AnalyzeInferred(context, symbols, reference.Method, reference.Syntax, reference.Syntax); + } + + private static void AnalyzeInferred(OperationAnalysisContext context, TypeKitSymbols symbols, IMethodSymbol method, SyntaxNode? name, SyntaxNode reported) + { if (!method.IsGenericMethod) return; // Written type arguments are a generic name, checked where they are written. - var expression = invocation.Syntax is InvocationExpressionSyntax call ? call.Expression : null; - if (expression is GenericNameSyntax || expression is MemberAccessExpressionSyntax { Name: GenericNameSyntax }) return; + if (name is GenericNameSyntax || name is MemberAccessExpressionSyntax { Name: GenericNameSyntax }) return; var parameters = method.OriginalDefinition.TypeParameters; var arguments = method.TypeArguments; @@ -102,7 +121,7 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitS for (var i = 0; i < parameters.Length; i++) { foreach (var breach in Breaches(symbols, parameters[i], arguments[i])) - context.Report(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), arguments[i].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), parameters[i].Name, Owner(method), breach)); + context.Report(Diagnostic.Create(Rule, reported.GetLocation(), arguments[i].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), parameters[i].Name, Owner(method), breach)); } } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs index a138155..e3729da 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -118,6 +118,49 @@ public static class Calls Cmtk0010(0, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A method group whose type arguments are inferred from the delegate it converts to has no + /// generic name and is no call: reported at the group, wherever it converts, a local function's + /// included. One with its arguments written is the generic name's, reported there once. + /// + [Fact] + public Task A_method_group_with_inferred_type_arguments_is_reported_at_the_group() => + Test( + """ + public static class Groups + { + public static void Register(Action handler) { } + + public static void Go() + { + Action run = {|#0:Runner.Run|}; + Register({|#1:Runner.Run|}); + var cast = (Action){|#2:Runner.Run|}; + Action seal = Runner.Seal; + Action sealedHandler = Runner.Seal; + Action open = {|#3:Runner.Seal|}; + Action local = {|#4:Local|}; + Action written = Runner.Run<{|#5:PlaceOrder|}>; + + static void Local<[Abstract] T>(T command) { } + } + + public static void Fine() + { + Action run = Runner.Run; + Register(Runner.Run); + Func> later = () => Runner.Run; + } + } + """, + Cmtk0010(0, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record"), + Cmtk0010(1, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record"), + Cmtk0010(2, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record"), + Cmtk0010(3, "Handler", "T", "Runner.Seal", "is not sealed"), + Cmtk0010(4, "PlaceOrder", "T", "Groups.Local", "is not an abstract class or record"), + Cmtk0010(5, "PlaceOrder", "T", "Runner.Run", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + /// A forwarding parameter carries the attribute, or the requirement ends at it. [Fact] public Task A_forwarding_type_parameter_must_carry_the_attribute() => From 2fa0693d8efa388c9bdf35402f961bded9474c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 18:43:38 +0200 Subject: [PATCH 031/147] fix: a closed set's All is read-only, so no cast replaces a member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review found All writable: the generated field held the array itself, typed IReadOnlyList, so ((IList)OrderStatus.All)[0] = … or a cast to T[] replaced a member. Every reader of All then saw the replacement, the OpenAPI enum and the fault mapping among them, while Parse, which reads the static fields, still answered the original. The field is now Array.AsReadOnly over the array: not an array, and a write through IList throws NotSupportedException. The test failed before the change, and undoes a write that succeeds so the set other tests read stays whole. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- design/plan.md | 4 +++ docs/closed-sets.md | 2 +- .../ClosedSetAspect.cs | 4 ++- src/CodoMetis.TypeKit.Generators/README.md | 2 +- .../ClosedSetTests.cs | 30 +++++++++++++++++++ 6 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06850a6..eb90b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ this release adds, so nothing that built against 1.5.1 meets them. - **The closed-set family**, the release's headline: `IClosedSet` in `CodoMetis.TypeKit`, generated by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static readonly` field, with constant data through a private constructor where the members carry some. The - generators add `Name`, `QualifiedName`, `All` in declaration order, `Match` with one required delegate + generators add `Name`, `QualifiedName`, `All` in declaration order and read-only, `Match` with one required delegate per member, `TryParse` and `Parse` by name, `ToString()`, a JSON converter that writes the name and reads the member itself back, a private constructor where none is declared, and the attribute the satellites read. CMTK1013 to CMTK1019 refuse a declaration that is not partial, not sealed, without diff --git a/design/plan.md b/design/plan.md index 04a1cb5..0808401 100644 --- a/design/plan.md +++ b/design/plan.md @@ -657,6 +657,10 @@ Each phase ends green, and its guards have been proven by seeding the defect type arguments from the delegate, with no generic name written and no call, and was silent. Every method reference is checked as an inferred call is, at the group; a group with its arguments written stays the generic name's, reported once. + - **A closed set's `All` was writable** ✅: it was the backing array, so a cast to + `IList` or `T[]` replaced a member that `All`, the JSON schema's enum and every + enumeration then listed, while `Parse`, which reads the fields, answered the original. It + is the array wrapped by `Array.AsReadOnly`, whose writes throw `NotSupportedException`. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the diff --git a/docs/closed-sets.md b/docs/closed-sets.md index f797a5e..8cc4897 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -41,7 +41,7 @@ bool known = Shipping.TryParse("Drone", out _); // false, nam Shipping.Parse("Drone"); // FormatException naming Shipping and member, never the name ``` -`All` holds the members in declaration order. `QualifiedName` keeps two sets that share a member +`All` holds the members in declaration order, read-only: no cast to `IList` or an array writes it. `QualifiedName` keeps two sets that share a member name apart in one log or one client. `ToString()` is the name, unless the set writes its own, which is kept, for a label, while the name stays what JSON, parsing and the column use. diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index 1388456..b51f512 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -112,13 +112,15 @@ public override void BuildAspect(IAspectBuilder builder) builder.AddInitializer(nameof(NamesInitializerTemplate), InitializerKind.BeforeTypeConstructor); + // The array wrapped, never the array itself: cast to IList or T[], it was writable, and a + // member replaced there was listed while Parse, which reads the fields, answered the original. var listType = TypeFactory.GetNamedType(typeof(IReadOnlyList<>)).WithTypeArguments(target); builder.IntroduceField(nameof(_all), IntroductionScope.Static, OverrideStrategy.Fail, f => { f.Accessibility = Accessibility.Private; f.Type = listType; f.Writeability = Writeability.ConstructorOnly; - f.InitializerExpression = ExpressionFactory.Parse($"new {ValueObjectTypes.SourceName(target)}[] {{ {string.Join(", ", members.Select(m => $"{ValueObjectTypes.SourceName(target)}.{m.Name}"))} }}"); + f.InitializerExpression = ExpressionFactory.Parse($"global::System.Array.AsReadOnly(new {ValueObjectTypes.SourceName(target)}[] {{ {string.Join(", ", members.Select(m => $"{ValueObjectTypes.SourceName(target)}.{m.Name}"))} }})"); f.AddAttribute(CodeAnnotations.CompilerGenerated); }); builder.IntroduceProperty(nameof(AllTemplate), buildProperty: p => diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index b375166..fe7dca7 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -211,7 +211,7 @@ public sealed partial class Tier : IClosedSet ``` The generators add `Name` from the field's name, `QualifiedName` (`OrderStatus.Open`), `All` in -declaration order, `Match` with one required delegate per member named after it, `TryParse` and +declaration order and read-only, `Match` with one required delegate per member named after it, `TryParse` and `Parse` by name, `ToString()`, a JSON converter that writes the name and reads the member itself back, and a private parameterless constructor where the type declares none: diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 7dcd17d..4df2ab9 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -20,6 +20,36 @@ public void All_holds_the_members_themselves_in_declaration_order() ProbeTier.All.Select(member => member.Name).ShouldBe(["Basic", "Premium"]); } + /// + /// All cannot be written through a cast: a member replaced there would be listed, offered + /// and documented while Parse still answered the original. A write that succeeds is undone + /// before the test fails, so the set other tests read stays whole. + /// + [Fact] + public void All_cannot_be_written_through_a_cast() + { + ProbeStatus.All.ShouldNotBeAssignableTo(); + + var list = (IList)ProbeStatus.All; + var replaced = false; + try + { + list[0] = ProbeStatus.Cancelled; + replaced = true; + } + catch (NotSupportedException) + { + } + finally + { + if (replaced) list[0] = ProbeStatus.Open; + } + + replaced.ShouldBeFalse("All was written through IList"); + Should.Throw(() => list.Clear()); + ProbeStatus.All[0].ShouldBeSameAs(ProbeStatus.Open); + } + [Fact] public void Name_QualifiedName_and_ToString_come_from_the_field() { From 400832364dd33c42cdb29482a750a1f451f57e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 18:48:17 +0200 Subject: [PATCH 032/147] fix: AsText refuses a value that renders nothing, and a selector that selects nothing A review found invisible text accepted: AsText() kept "\u200D" (a joiner with nothing to join) and "\uFE0F" (a variation selector, which Unicode files as a non-spacing mark, so the category test never reached it). Both guards also kept a lone combining mark, and a selector after a space, a joiner or another selector. A value now needs a base character, one drawn on its own: not a space, a mark, a joiner or a selector. A variation selector stands only directly after one (the 16 of the BMP, the 240 ideographic ones, the Mongolian free variation selectors); anywhere else it selects nothing and is Hidden. What the joiners and selectors are for survives, pinned by escape: emoji presentation and text presentation, a rainbow flag, a keycap, an ideographic variation sequence, a Persian word with its non-joiner, a Devanagari conjunct, and an Arabic letter in its joining form on its own, which ends with a joiner, so joiners are not placed. Seeds: no base required, a selector anywhere, a space, a joiner or a mark counted as a base, the ideographic range dropped, each failing its test; and an over-strict base (symbols excluded), which the surviving sequences catch. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- design/plan.md | 6 ++ docs/text.md | 3 + src/CodoMetis.TypeKit/README.md | 5 +- src/CodoMetis.TypeKit/StringTextExtensions.cs | 50 ++++++++++++++++- .../Docs/TextSamples.cs | 8 ++- .../StringTextExtensionsTests.cs | 56 +++++++++++++++++++ 7 files changed, 125 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb90b8c..9e134b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,7 +92,9 @@ this release adds, so nothing that built against 1.5.1 meets them. filler or NUL, which `IsNullOrWhiteSpace` lets through; the two zero-width joiners are kept, since they spell Persian, Arabic and Indic scripts and emoji sequences. `text.AsIdentifier()` is for a value another system issued, a subject claim or a session id: trimmed and otherwise left as it - arrived, with the joiners refused too. Both answer `Result`, where `Blank` is + arrived, with the joiners refused too. A variation selector is kept after the character it varies + and refused anywhere else, and a value of joiners, variation selectors and combining marks alone, + which renders nothing, is refused by both. Both answer `Result`, where `Blank` is the caller's to read as an error or as absence and `Hidden` is a refusal in both; a null receiver is `Blank`. A value object over either is one line, shown in the README. - **`CodoMetis.TypeKit`**: `FirstOrNone()`, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` diff --git a/design/plan.md b/design/plan.md index 0808401..f330b18 100644 --- a/design/plan.md +++ b/design/plan.md @@ -661,6 +661,12 @@ Each phase ends green, and its guards have been proven by seeding the defect `IList` or `T[]` replaced a member that `All`, the JSON schema's enum and every enumeration then listed, while `Parse`, which reads the fields, answered the original. It is the array wrapped by `Array.AsReadOnly`, whose writes throw `NotSupportedException`. + - **Invisible text passed `AsText()`** ✅: a value of joiners alone (`"\u200D"`), of variation + selectors alone (filed as non-spacing marks, so no category test reached them), or of a + lone combining mark rendered nothing and was text. A value now needs a base character, and + a variation selector stands only directly after one, so emoji presentation, a flag, a + keycap and an ideographic variant survive; a joiner is not placed, since an Arabic letter + shown in its joining form on its own ends with one. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the diff --git a/docs/text.md b/docs/text.md index 25d8760..d20cd5a 100644 --- a/docs/text.md +++ b/docs/text.md @@ -18,6 +18,8 @@ using them: which one, where, and inside a value object. The examples import `Co | Composes to NFC, so one spelling per name | yes | no, since an issued value is compared against what its issuer sends next time | | Refuses a character that renders as nothing | yes | yes | | The two zero-width joiners | kept: they spell Persian, Arabic and Indic scripts and hold an emoji sequence together | refused: no issuer needs one | +| A variation selector | kept after the character it varies (an emoji's presentation, an ideograph's variant); elsewhere it selects nothing and is refused | the same | +| A value of joiners, variation selectors and combining marks alone | refused: it renders no character of its own | refused | | A null receiver | `Blank` | `Blank` | ```csharp @@ -26,6 +28,7 @@ Result folded = "Jean\u2003Paul".AsText(); // Succe Result joined = "\U0001F469\u200D\U0001F4BB".AsText(); // Success: 👩‍💻, the joiner holds the sequence together Result hidden = "Zoe\u200B".AsText(); // Error: Hidden, a zero-width space Result filler = "\u3164".AsText(); // Error: Hidden, a Hangul filler that IsNullOrWhiteSpace accepts +Result bare = "\u200D".AsText(); // Error: Hidden, a joiner with nothing to join Result issued = " a1b2-c3 ".AsIdentifier(); // Success: a1b2-c3, trimmed and otherwise as issued Result forged = "sub\u200Dject".AsIdentifier(); // Error: Hidden, a joiner in an identifier ``` diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 3d5c268..fa6f12e 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -343,7 +343,10 @@ trims, folds every space separator to an ordinary space, composes to NFC and ref that renders as nothing, keeping the two zero-width joiners because they spell Persian, Arabic and Indic scripts and hold an emoji sequence together. `AsIdentifier()` is for a value another system issued, a subject claim, a session id, a key: it trims and alters nothing else, since an issued -value is compared against what its issuer sends next time, and it refuses the joiners too. Both +value is compared against what its issuer sends next time, and it refuses the joiners too. A +variation selector is kept after the character it varies and refused anywhere else, and a value of +joiners, selectors and combining marks alone renders no character of its own and is refused by +both. Both scan by `Rune`, so a character above the BMP is seen for what it is, and an unpaired surrogate is `Hidden` rather than an exception. diff --git a/src/CodoMetis.TypeKit/StringTextExtensions.cs b/src/CodoMetis.TypeKit/StringTextExtensions.cs index 1c9bf5c..aa89c8a 100644 --- a/src/CodoMetis.TypeKit/StringTextExtensions.cs +++ b/src/CodoMetis.TypeKit/StringTextExtensions.cs @@ -120,9 +120,25 @@ private static string FoldSpaces(string text) return folded?.ToString() ?? text; } + /// + /// Whether the text carries a character that renders as nothing, or renders no character of its + /// own at all. + /// + /// + /// + /// A joiner, a variation selector and a combining mark render only beside a base character, so a + /// value of nothing else, "\u200D" or "\uFE0F", shows nothing and is hidden. A variation + /// selector chooses how the character before it is drawn: after a base it is an emoji's + /// presentation or an ideograph's variant, and anywhere else it selects nothing. A joiner is not + /// placed: an Arabic letter shown in its joining form on its own ends with one, which is + /// orthography; a value of joiners alone is caught by needing a base. + /// + /// private static bool IsHidden(string text, bool tolerateJoiners) { - var rest = text.AsSpan(); + var rest = text.AsSpan(); + var afterBase = false; + var rendersBase = false; while (!rest.IsEmpty) { @@ -131,12 +147,42 @@ private static bool IsHidden(string text, bool tolerateJoiners) if (!IsRendered(rune, tolerateJoiners)) return true; + if (IsVariationSelector(rune)) + { + if (!afterBase) return true; + afterBase = false; + } + else + { + afterBase = IsBase(rune); + rendersBase |= afterBase; + } + rest = rest[consumed..]; } - return false; + return !rendersBase; } + /// + /// A character drawn on its own: not a space, not a mark drawn on another, not a joiner and not a + /// variation selector. + /// + private static bool IsBase(Rune rune) => + rune.Value is not (ZeroWidthNonJoiner or ZeroWidthJoiner) + && !Rune.IsWhiteSpace(rune) + && Rune.GetUnicodeCategory(rune) is not (UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark); + + /// + /// The variation selectors, which Unicode files as non-spacing marks: the sixteen of the BMP, the + /// 240 ideographic ones, and the Mongolian free variation selectors. + /// + private static bool IsVariationSelector(Rune rune) => + rune.Value is >= 0xFE00 and <= 0xFE0F + or >= 0xE0100 and <= 0xE01EF + or >= 0x180B and <= 0x180D + or 0x180F; + private static bool IsRendered(Rune rune, bool tolerateJoiners) => (tolerateJoiners && rune.Value is ZeroWidthNonJoiner or ZeroWidthJoiner) || (Rune.GetUnicodeCategory(rune) switch diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs index 04b9e1c..d7be3c1 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/TextSamples.cs @@ -27,7 +27,7 @@ public static Result Create(string value) => public static class Usage { - public static (Result Composed, Result Folded, Result Joined, Result Hidden, Result Filler, Result Issued, Result Forged) Guards() + public static (Result Composed, Result Folded, Result Joined, Result Hidden, Result Filler, Result Bare, Result Issued, Result Forged) Guards() { // sample: text/guards Result composed = "Zoe\u0308".AsText(); // Success: Zoë, composed to NFC: one code point for ë @@ -35,11 +35,12 @@ public static (Result Composed, Result Fol Result joined = "\U0001F469\u200D\U0001F4BB".AsText(); // Success: 👩‍💻, the joiner holds the sequence together Result hidden = "Zoe\u200B".AsText(); // Error: Hidden, a zero-width space Result filler = "\u3164".AsText(); // Error: Hidden, a Hangul filler that IsNullOrWhiteSpace accepts + Result bare = "\u200D".AsText(); // Error: Hidden, a joiner with nothing to join Result issued = " a1b2-c3 ".AsIdentifier(); // Success: a1b2-c3, trimmed and otherwise as issued Result forged = "sub\u200Dject".AsIdentifier(); // Error: Hidden, a joiner in an identifier // end sample - return (composed, folded, joined, hidden, filler, issued, forged); + return (composed, folded, joined, hidden, filler, bare, issued, forged); } // sample: text/required-optional @@ -90,10 +91,11 @@ public void Each_guard_answers_what_its_comment_states() "\U0001F469\u200D\U0001F4BB".AsText().ShouldBeShownIn(Readme, Outcome); "Zoe\u200B".AsText().ShouldBeShownIn(Readme, Outcome); "\u3164".AsText().ShouldBeShownIn(Readme, Outcome); + "\u200D".AsText().ShouldBeShownIn(Readme, Outcome); " a1b2-c3 ".AsIdentifier().ShouldBeShownIn(Readme, Outcome); "sub\u200Dject".AsIdentifier().ShouldBeShownIn(Readme, Outcome); - var (composed, _, _, _, _, _, _) = Usage.Guards(); + var (composed, _, _, _, _, _, _, _) = Usage.Guards(); composed.TryGetValue(out var zoe, out _).ShouldBeTrue(); zoe.ShouldBe("Zoë", "composed: one code point"); zoe.Length.ShouldBe(3); diff --git a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs index 509ba9d..7cd45d6 100644 --- a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs +++ b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs @@ -104,6 +104,62 @@ public void An_emoji_sequence_survives_as_text() Kept(developer.AsText()).ShouldBe(developer); } + /// + /// Characters that render only beside a base, as a whole value: a joiner, a variation selector or + /// a combining mark with nothing to join, select or sit on renders as nothing, and the value is + /// hidden rather than blank, whichever guard reads it. + /// + [Theory] + [InlineData("\u200D", "ZERO WIDTH JOINER")] + [InlineData("\u200C\u200D", "ZERO WIDTH NON-JOINER, ZERO WIDTH JOINER")] + [InlineData(" \u200D ", "ZERO WIDTH JOINER between spaces")] + [InlineData("\uFE0F", "VARIATION SELECTOR-16")] + [InlineData("\uFE0E\uFE0F", "VARIATION SELECTOR-15, VARIATION SELECTOR-16")] + [InlineData("\U000E0100", "VARIATION SELECTOR-17")] + [InlineData("\u180B", "MONGOLIAN FREE VARIATION SELECTOR ONE")] + [InlineData("\u200D\uFE0F", "ZERO WIDTH JOINER, VARIATION SELECTOR-16")] + [InlineData("\u0301", "COMBINING ACUTE ACCENT")] + [InlineData("\u0301\u200D", "COMBINING ACUTE ACCENT, ZERO WIDTH JOINER")] + public void A_value_that_renders_no_character_of_its_own_is_hidden(string value, string name) + { + Refusal(value.AsText()).ShouldBe(TextFault.Hidden, name); + Refusal(value.AsIdentifier()).ShouldBe(TextFault.Hidden, name); + } + + /// + /// A variation selector chooses how the character before it is drawn, and draws nothing itself: + /// after a space, another selector, a joiner or nothing at all, it selects nothing and is a + /// hidden character in visible text. + /// + [Theory] + [InlineData("\uFE0FMustermann", "first")] + [InlineData("Muster \uFE0Fmann", "after a space")] + [InlineData("Muster\u2764\uFE0F\uFE0Fmann", "after another selector")] + [InlineData("Muster\u200D\uFE0Fmann", "after a joiner")] + [InlineData("Muster \U000E0100mann", "an ideographic selector after a space")] + public void A_variation_selector_that_selects_nothing_is_hidden(string value, string where) + { + Refusal(value.AsText()).ShouldBe(TextFault.Hidden, where); + Refusal(value.AsIdentifier()).ShouldBe(TextFault.Hidden, where); + } + + /// + /// What the joiners and the selectors are for survives as text: emoji presentation, a flag and a + /// keycap, an ideographic variant, a Persian word with its non-joiner, a Devanagari conjunct, and + /// an Arabic letter shown in its joining form on its own, where the joiner ends the value. + /// + [Theory] + [InlineData("\u2764\uFE0F", "RED HEART, emoji presentation")] + [InlineData("\u2764\uFE0E", "RED HEART, text presentation")] + [InlineData("\U0001F3F3\uFE0F\u200D\U0001F308", "RAINBOW FLAG")] + [InlineData("1\uFE0F\u20E3", "KEYCAP DIGIT ONE")] + [InlineData("\u845B\U000E0100", "an ideographic variation sequence")] + [InlineData("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645", "Persian, with a non-joiner")] + [InlineData("\u0915\u094D\u200D\u0937", "Devanagari, a conjunct with a joiner")] + [InlineData("\u0647\u200D", "Arabic HEH in its initial form")] + public void A_variation_or_joining_sequence_survives_as_text(string value, string name) => + Kept(value.AsText()).ShouldBe(value, name); + /// /// The two faults stay apart because the caller decides what a blank means: a required field /// turns it into an error, an optional one into absence. Collapsing them would make every From 59dff7ff720b42b92eaa2e3c1f28ec879be7ba8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 18:53:44 +0200 Subject: [PATCH 033/147] docs: a 1.5-to-2.0 migration guide, the AOT query-helper limitation measured, no stale roadmap A review found the docs behind the release: - the README listed fault responses, documented since step 8 in the ASP.NET Core README and guide, and public aspects, which none is planned, as still to come; the plan called phase 13 not started; - nothing told a 1.5 user what builds or runs differently; - the Native AOT page did not say that the query helpers cannot run there. docs/migrating-to-2.md, compiled like every page and listed in the README's table: the packages move together; CMTK0003 is an error and what it newly reports, answered in code, lowered with .editorconfig to migrate in steps; CMTK0010-0012 meet no 1.5 code; a query's FirstOrNone and FirstOrError run in the database, LastOrNone on a query a compile error; the names 2.0 adds, where an instance member silently replaces an extension of the consumer's; fault responses unchanged. The limitation is measured before it is written: the smoke test's AOT consumer calls the base package's FirstOrNone on a query and the EF satellite's FirstOrNoneAsync, which throw "Query wasn't precompiled and dynamic code isn't supported with NativeAOT", and the workaround the docs give, the operators written at the call site, which runs. All three are asserted. The Native AOT page and the query overloads' remarks say so. Seeds: the page's block drifting from its region, and the page unlisted, each failing its convention test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- README.md | 6 +- design/plan.md | 15 ++- docs/migrating-to-2.md | 98 +++++++++++++++++++ docs/native-aot.md | 8 ++ src/CodoMetis.TypeKit/OptionExtensions.cs | 8 ++ src/CodoMetis.TypeKit/ResultExtensions.cs | 2 +- .../Docs/MigratingSamples.cs | 50 ++++++++++ test/consumer-smoke-test.sh | 12 +++ 9 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 docs/migrating-to-2.md create mode 100644 test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e134b7..fe661b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,7 +124,8 @@ this release adds, so nothing that built against 1.5.1 meets them. for an optional input validated when present. - **Documentation**: topic pages under `docs/`, linked from the root README's "Documentation" table: value objects, closed sets, `Option`, `Result`, written text, type constraints, merge patches, - JSON, EF Core, ASP.NET Core, Native AOT, logging and secrets, and testing. Every C# block in them is compiled and its stated + JSON, EF Core, ASP.NET Core, Native AOT, logging and secrets, testing, and migrating from 1.5, + what builds or runs differently for code that built against it. Every C# block in them is compiled and its stated outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete public surface, checked against the types. The package READMEs remain the reference for each package. The plan moved from `docs/plan.md` to `design/plan.md`. diff --git a/README.md b/README.md index 88beebe..0bec7ec 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,11 @@ compiled and its stated outputs are tested, as the READMEs' are. | [Merge patches](docs/merge-patches.md) | `Patch`: absent, null or a value, applying a patch, value objects inside one, writing one, the OpenAPI document, source-generated contexts | | [JSON](docs/json.md) | What a value object writes and reads, refusals, stored documents, `Option` and `Result` at the wire, a converter of your own, source-generated contexts | | [EF Core](docs/ef-core.md) | Columns, keys, conventions, queries and the SQL they become, seed data, reading back without validation, SQLite in tests | -| [ASP.NET Core](docs/aspnet-core.md) | Binding in minimal APIs and MVC, what a refused value answers, a `Result` as a response, the OpenAPI document, problem details | +| [ASP.NET Core](docs/aspnet-core.md) | Binding in minimal APIs and MVC, what a refused value answers, a `Result` as a response, fault responses a client can act on, the OpenAPI document, problem details | | [Native AOT](docs/native-aot.md) | What is measured, the project settings, JSON contexts, OpenAPI, EF Core's compiled model, configuration binding, discovery without reflection | | [Logging and secrets](docs/logging-and-secrets.md) | A value that must not be printed, what still carries it, Serilog, Microsoft.Extensions.Logging, refusals that never quote the input | | [Testing](docs/testing.md) | Assertions for `Option` and `Result`, table-driven tests of `Create`, EF Core on SQLite, endpoints on the test server | - -Still to come in 2.0.0, each page written when its feature lands ([the plan](design/plan.md#2-phases), -phase 13): fault responses a client can act on, and public aspects. +| [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) | What builds or runs differently: CMTK0003 as an error, a query's `FirstOrNone` in the database, names that 2.0 adds, fault responses, Native AOT | ## What it holds to diff --git a/design/plan.md b/design/plan.md index f330b18..23a3326 100644 --- a/design/plan.md +++ b/design/plan.md @@ -537,8 +537,8 @@ Each phase ends green, and its guards have been proven by seeding the defect `RangeSet<,>`, whose equality is a value's, though `IEquatable` alone is no criterion (`ImmutableArray`). -13. **2.0.0: closed sets, fault responses, merge patches, exceptions, constraints and text.** Next; - not started. Scoped 2026-10-12 (decision 42): the features proposed for 1.x ship in the major, +13. **2.0.0: closed sets, fault responses, merge patches, exceptions, constraints and text.** In + progress: steps 1 to 9a done (2026-10-13), the preview and the release to come. Scoped 2026-10-12 (decision 42): the features proposed for 1.x ship in the major, not as a 1.6.0 before it, because the smart-enum family changes what a fault is, and fault responses built on enums first would be built twice. Pinned 2026-10-13 (decision 44) to `net10.0` and Metalama 2026.1: nothing in it needs .NET 11, C# 15 unions or Metalama 2027.0, and @@ -667,6 +667,13 @@ Each phase ends green, and its guards have been proven by seeding the defect a variation selector stands only directly after one, so emoji presentation, a flag, a keycap and an ideographic variant survive; a joiner is not placed, since an Arabic letter shown in its joining form on its own ends with one. + - **The docs** ✅: the README no longer lists fault responses (documented in the ASP.NET Core + README and guide since step 8) or public aspects (none planned) as still to come; + `docs/migrating-to-2.md` lists what builds or runs differently for 1.5 code, compiled like + every page; and the Native AOT page states the query helpers' limitation, which the smoke + test's `aot` consumer now measures: the base package's `FirstOrNone` on a query and the + satellite's `FirstOrNoneAsync` throw "Query wasn't precompiled", and the operators written + at the call site run. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the @@ -1533,7 +1540,9 @@ Still open: The plan and the design notes moved to `design/`, so that `docs/` holds only what a consumer reads and nothing there is exempt from compilation; `design/` is a solution folder. Eleven pages came first; `merge-patches.md` and `closed-sets.md` joined them once the docs branch merged into - 2.0.0 (2026-10-13), and the fault responses and public aspects get theirs as they land. + 2.0.0 (2026-10-13). The fault responses are in the ASP.NET Core and closed-set pages, no public + aspect is planned (decision 43 allows one, and none exists), and `migrating-to-2.md` lists what + builds or runs differently for 1.5 code. 46. **A timeout caught by `Result.Catch` is the error** (2026-10-13, phase 13). Every overload let an `OperationCanceledException` through, and `HttpClient` throws one over a `TimeoutException` diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md new file mode 100644 index 0000000..41729f4 --- /dev/null +++ b/docs/migrating-to-2.md @@ -0,0 +1,98 @@ +# Migrating from 1.5 to 2.0 + +2.0 targets `net10.0` and Metalama 2026.1, as 1.5 does: no runtime, SDK or Metalama version moves +with it. This page lists what builds or runs differently for code that built against 1.5, and what +to write instead; the [changelog](../CHANGELOG.md) lists everything 2.0 adds. The examples import +`CodoMetis.TypeKit`. + +## Update the packages + +The packages share one version and move together: raise every one you reference, satellites +included. + +```xml + + + +``` + +## A dropped Result is a build error + +CMTK0003, a `Result` or `Option` a call returns and nobody reads, was a warning in 1.x and is an +error in 2.0. It also reports what 1.x missed: `Wait()` on a task of a result, tasks of results +passed to `Task.WaitAll` or `Task.WaitAny`, a `foreach` or `await foreach` that never reads the +results it walks, and a lazy sequence of results turned into a list and dropped. A build that +treated warnings as errors already met every 1.x form, so the new ones are what to look for. + +```csharp +await orders.CancelAsync(id); // CMTK0003: an error since 2.0 +orders.CancelAsync(id).Wait(); // CMTK0003: new in 2.0, waited for and forgotten + +_ = await orders.CancelAsync(id); // the drop, said in the code +await foreach (var _ in orders.CancelEachAsync(ids)) { } // a loop variable named _ says it too +``` + +Every report is answered in the code and never needs a suppression: `_ =` for a drop nothing +depends on, a cast to `Task` for a task converted on purpose, a loop variable named `_`. To migrate +a large codebase in steps, lower the rule for a while: + +```ini +[*.cs] +dotnet_diagnostic.CMTK0003.severity = warning +``` + +CMTK0010, CMTK0011 and CMTK0012 are new errors too, but each reports only on what 2.0 adds: the +type-parameter attributes, `[RequireParameterlessConstructor]` and a closed set's `Match`. No code +that built against 1.5 meets them. + +## A query's FirstOrNone runs in the database + +In 1.5, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` on an EF Core query bound to +the `IEnumerable` overloads: every row was read and the condition ran in memory. In 2.0 they bind +to `IQueryable` overloads that hand the condition to the provider, so the answer is the same and +one row is read. A condition the provider cannot translate now throws where it used to run in +memory; say so with `AsEnumerable()`. `LastOrNone` and `LastOrError` on a query are compile errors +whose message says what to write instead. + +```csharp +Option large = orders.FirstOrNone(o => o.Total > 100); // 2.0: WHERE and LIMIT 1 in the query; 1.5 read every row +Option local = orders.AsEnumerable().FirstOrNone(o => o.IsLarge()); // a condition the database cannot run, in memory as before +Option latest = orders.OrderByDescending(o => o.Placed).FirstOrNone(); // where 1.5 took LastOrNone(), now a compile error on a query +``` + +Under Native AOT, EF runs only the queries it precompiled at the application's own call sites, and +these helpers compose the query inside the package: they throw there, as +[Native AOT](native-aot.md#ef-core) describes, with the operators to write at the call site instead. + +## Names 2.0 adds + +2.0 adds members to types 1.5 already had. An extension of your own with the same name and +parameters, imported beside the package's, makes a call ambiguous (CS0121); delete yours. Where the +new member is an instance member, nothing is ambiguous and yours is silently no longer called: a +`MapAsync`, `BindAsync` or `TapAsync` of yours on `Option`. + +| On | Added | +|---|---| +| `Option` | `MapAsync`, `BindAsync`, `TapAsync` (instance members), `Traverse` | +| `Task>` | `MapAsync`, `BindAsync`, `FilterAsync`, `TapAsync`, `ToResultAsync` | +| `Option>`, `Result, TError>` | `Transpose` | +| a sequence | `SingleOrNone`, `ElementAtOrNone` | +| a query | `FirstOrNone`, `FirstOrError`, `SingleOrNone`, `ElementAtOrNone` | +| an EF Core query | `FirstOrNoneAsync`, `SingleOrNoneAsync`, in EF Core's namespace | +| `string` | `AsText`, `AsIdentifier` | +| `Result` | `Catch`, `CatchAsync` | + +## Fault responses + +Nothing to change. `AddFaultResponses(fault => …)` maps an enum as it did, and answers with +the same response. `FaultProblem` takes any non-null fault type where it took `struct, Enum`, +so that a closed set can be one, and every type argument it accepted still is. To answer each fault +by its kind instead of by a switch, make the fault type a closed set: +[Closed sets](closed-sets.md#as-a-fault-type). + +## What is new + +[Closed sets](closed-sets.md), [merge patches](merge-patches.md), +[an exception as an outcome](result.md#an-exception-as-an-outcome), [written text](text.md), +[type constraints](type-constraints.md), and [fault responses](aspnet-core.md#a-result-as-a-response) +a client can act on, each with its page. diff --git a/docs/native-aot.md b/docs/native-aot.md index c9c3c63..d49536e 100644 --- a/docs/native-aot.md +++ b/docs/native-aot.md @@ -85,6 +85,14 @@ a sealed entity class does not compile in precompiled queries; and a converter n `HasConversion` is not what the precompiled queries cast to, so the mapping stays `UseTypeKit()`'s ([EF Core](ef-core.md#compiled-models)). +EF precompiles a query where the application calls one of EF's operators, and runs no other. The +query helpers compose their query inside the package, where it never looks: `FirstOrNone`, +`FirstOrError`, `SingleOrNone` and `ElementAtOrNone` on a query, and the EF satellite's +`FirstOrNoneAsync` and `SingleOrNoneAsync`, throw `InvalidOperationException: Query wasn't +precompiled and dynamic code isn't supported with NativeAOT.` Write the operators at the call site +and take the option of the list: `(await db.Orders.Where(o => o.Total > 100).Take(1).ToListAsync()).FirstOrNone()`. +The smoke test's consumer measures both the refusal and that call. + ## Configuration binding `PublishAot` and `PublishTrimmed` turn on the configuration binding source generator, which binds diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index 91a714b..41edb95 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -491,10 +491,18 @@ public Option ElementAtOrNone(Index index) /// The query. /// The type of the elements. /// + /// /// A query is asked for one element, so the provider runs the condition and the limit where the /// query runs: a WHERE and a LIMIT in SQL. Through the overloads, an /// was read in full and its condition compiled and run in memory, on /// every row of the table. + /// + /// + /// Under Native AOT, EF Core runs only the queries it precompiled where the application calls its + /// operators, and these compose the query here: they throw "Query wasn't precompiled" (measured + /// with EF Core 10.0.12 in the smoke test). Write Where(…).Take(1).ToList() at the call site + /// and take FirstOrNone() of the list. + /// /// extension(IQueryable source) where T : notnull { diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 7d4717d..8a23c48 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -924,7 +924,7 @@ error is null /// The query. /// The type of the elements. /// The type of the error. - /// The provider runs the condition and is asked for one element, as Option.FirstOrNone on a query is. + /// The provider runs the condition and is asked for one element, as Option.FirstOrNone on a query is, and like it, the query is not precompiled under Native AOT and throws there. extension(IQueryable source) where T : notnull where TError : notnull { /// The first element that matches, or the given error: the provider runs the condition, and is asked for one. diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs new file mode 100644 index 0000000..fa5f564 --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs @@ -0,0 +1,50 @@ +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ValueObjects; + +namespace ReadmeSamples.Docs.Migrating; + +public readonly partial record struct OrderId : IValue; + +public enum OrderFault { Unknown } + +public interface IOrders +{ + Task> CancelAsync(OrderId id); + + IAsyncEnumerable> CancelEachAsync(IEnumerable ids); +} + +public sealed record Order(OrderId Id, decimal Total, DateTimeOffset Placed) +{ + public bool IsLarge() => Total > 1_000; +} + +public static class Usage +{ + /// The silent part is compiled outside the suppression, so a report on it fails the build. + public static async Task Dropped(IOrders orders, OrderId id, IEnumerable ids) + { +#pragma warning disable CMTK0003 // what the page shows the rule reporting + // sample: migrating-to-2/dropped + await orders.CancelAsync(id); // CMTK0003: an error since 2.0 + orders.CancelAsync(id).Wait(); // CMTK0003: new in 2.0, waited for and forgotten + + // end sample +#pragma warning restore CMTK0003 + // sample: migrating-to-2/dropped + _ = await orders.CancelAsync(id); // the drop, said in the code + await foreach (var _ in orders.CancelEachAsync(ids)) { } // a loop variable named _ says it too + // end sample + } + + public static (Option Large, Option Local, Option Latest) Queries(IQueryable orders) + { + // sample: migrating-to-2/queries + Option large = orders.FirstOrNone(o => o.Total > 100); // 2.0: WHERE and LIMIT 1 in the query; 1.5 read every row + Option local = orders.AsEnumerable().FirstOrNone(o => o.IsLarge()); // a condition the database cannot run, in memory as before + Option latest = orders.OrderByDescending(o => o.Placed).FirstOrNone(); // where 1.5 took LastOrNone(), now a compile error on a query + // end sample + + return (large, local, latest); + } +} diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index e9b604f..34c1b06 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -826,6 +826,7 @@ run dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version "$ef_versi run dotnet add package Microsoft.EntityFrameworkCore.Design --version "$ef_version" cat > Program.cs <<'CSHARP' +using CodoMetis.TypeKit; using Microsoft.EntityFrameworkCore; using Shop; @@ -871,6 +872,14 @@ await Check("ef-stored", async () => return (await db.Orders.Where(o => o.Id == id).SingleAsync()).Code.Value; }); db.ChangeTracker.Clear(); +// The query helpers compose the query inside the package, where EF's precompiler, which works on +// the application's own call sites, never sees it: measured here, with the workaround the docs give. +await Check("ef-first-or-none", () => Task.FromResult(db.Orders.FirstOrNone(o => o.Code.Value.StartsWith("lo")).IsSome().ToString())); +db.ChangeTracker.Clear(); +await Check("ef-first-or-none-async", async () => (await db.Orders.FirstOrNoneAsync(o => o.Code.Value.StartsWith("lo"))).IsSome().ToString()); +db.ChangeTracker.Clear(); +await Check("ef-first-or-none-written", async () => (await db.Orders.Where(o => o.Code.Value.StartsWith("lo")).Take(1).ToListAsync()).FirstOrNone().IsSome().ToString()); +db.ChangeTracker.Clear(); await db.Database.ExecuteSqlRawAsync("CREATE TABLE Shipments (Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, Carrier TEXT NOT NULL)"); await Check("ef-identity", async () => { @@ -972,6 +981,9 @@ assert_contains "ef-identity=1,2" "$work/aot.out" "a key over a assert_contains 'ef-set-stored=Shipped {"Quantity":3,"Status":"Open"}' "$work/aot.out" "a closed set is its name in a column and in a JSON column, through the compiled model" assert_contains "ef-set-parameter=Shipped" "$work/aot.out" "a closed-set parameter is mapped at run time" assert_contains "ef-json-column=Open 3" "$work/aot.out" "a JSON column reads a closed set and a value object back in a precompiled query" +assert_contains "ef-first-or-none=THREW InvalidOperationException: Query wasn't precompiled" "$work/aot.out" "a query helper is not precompiled, as the docs say: the base package's FirstOrNone on a query" +assert_contains "ef-first-or-none-async=THREW InvalidOperationException: Query wasn't precompiled" "$work/aot.out" "and the EF satellite's FirstOrNoneAsync" +assert_contains "ef-first-or-none-written=True" "$work/aot.out" "the docs' workaround, the operators written at the call site, runs" [[ $failed -eq 0 ]] || { echo; echo "consumer smoke test FAILED"; exit 1; } echo From 501e2fdd2c6041aa40eeca21924fd377a2d9ce4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 19:37:26 +0200 Subject: [PATCH 034/147] feat: Choose, a sequential TraverseAsync, a patch's Map and Traverse, and patch assertions `Choose(selector)` keeps the values a selector answers Some for, in order and lazily, a filter and a map in one; `Choose()` the values a sequence of options holds. Both check their arguments at the call, as LINQ does. `TraverseAsync` awaits one operation at a time, in order, and starts none after the first error; the token overloads check the token before each operation and hand it on, and a cancellation propagates rather than becoming an error. `Patch.Map` changes a value and keeps a clear and an absence; `Traverse` validates a value with Create's own fault, so a patch over what the request sent becomes a patch over the domain's type, and a Map selector that answers null is refused, since a clear is the Null state and never a value. `.Testing` asserts a patch's state: ShouldBeValue() hands the value back, ShouldBeNull() and ShouldBeAbsent() name the state and never the value. Each new delegate has its null case and each new result-taking extension its uninitialized case in the completeness tests, and the Option and Result pages' surface tables have their rows. Seeded: without the null refusal and the cancellation checks, four tests fail. Co-Authored-By: Claude Fable 5.1 --- docs/merge-patches.md | 26 ++++ docs/option.md | 8 +- docs/result.md | 14 ++ docs/testing.md | 20 +++ .../PatchAssertions.cs | 45 ++++++ src/CodoMetis.TypeKit.Testing/README.md | 23 ++- .../TypeKitAssertionException.cs | 2 +- src/CodoMetis.TypeKit/OptionExtensions.cs | 39 +++++ src/CodoMetis.TypeKit/Patch.cs | 20 +++ src/CodoMetis.TypeKit/README.md | 14 +- src/CodoMetis.TypeKit/ResultExtensions.cs | 145 ++++++++++++++++++ src/CodoMetis.TypeKit/ThrowHelper.cs | 9 ++ .../Docs/MergePatchesSamples.cs | 32 ++++ .../Docs/OptionSamples.cs | 12 +- .../Docs/ResultSamples.cs | 32 ++++ .../Docs/TestingSamples.cs | 16 ++ .../Testing/TestingSamples.cs | 19 +++ .../PatchAssertionTests.cs | 53 +++++++ test/CodoMetis.TypeKit.Tests/ChooseTests.cs | 62 ++++++++ .../NullDelegateTests.cs | 12 ++ .../PatchCombinationTests.cs | 77 ++++++++++ .../RefusalLinkTests.cs | 1 + .../ResultTraverseAsyncTests.cs | 136 ++++++++++++++++ .../UninitializedResultTests.cs | 5 + 24 files changed, 808 insertions(+), 14 deletions(-) create mode 100644 src/CodoMetis.TypeKit.Testing/PatchAssertions.cs create mode 100644 test/CodoMetis.TypeKit.Testing.Tests/PatchAssertionTests.cs create mode 100644 test/CodoMetis.TypeKit.Tests/ChooseTests.cs create mode 100644 test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs create mode 100644 test/CodoMetis.TypeKit.Tests/ResultTraverseAsyncTests.cs diff --git a/docs/merge-patches.md b/docs/merge-patches.md index c4f4186..b3f9b89 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -100,6 +100,32 @@ In an endpoint, that is the 400 problem details any body a value object refuses not a refusal but the `Null` state, which `Apply` turns into a fault of its own, so a `Patch` is never needed. +## Validating one + +Where the refusal should be the endpoint's own fault rather than a 400, the request carries a patch +over what was sent, and `Traverse` validates it into a patch over the domain's type: the value is +validated when the field carried one, with `Create`'s own fault, and a clear or an absent field +passes through, since there is nothing to validate. `Map` changes the value the same way and keeps +a clear and an absence as they are. + +```csharp +public sealed record ContactRequest(Patch Email, Patch Floor); +``` + +```csharp +ContactRequest nobody = JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web)!; +ContactRequest cleared = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; +ContactRequest moved = JsonSerializer.Deserialize("""{"floor":3}""", JsonSerializerOptions.Web)!; + +Result, EmailFault> refused = nobody.Email.Traverse(Email.Create); // Error: NoAt, the endpoint's own fault +Result, EmailFault> clear = cleared.Email.Traverse(Email.Create); // Success: Null, nothing to validate +Result, EmailFault> untouched = moved.Email.Traverse(Email.Create); // Success: Absent +Patch tenfold = moved.Floor.Map(floor => floor * 10); // Value: 30, a clear and an absence kept as they are +``` + +`CodoMetis.TypeKit.Testing` asserts the state: `ShouldBeValue()` hands the value back, `ShouldBeNull()` +is a clear and `ShouldBeAbsent()` a field the document left out ([Testing](testing.md)). + ## Writing one A client, or a test, writes a patch body with the same type. A converter cannot leave its property diff --git a/docs/option.md b/docs/option.md index ad9b059..ba17638 100644 --- a/docs/option.md +++ b/docs/option.md @@ -64,6 +64,8 @@ Option discount = found.Filter(customer => customer.Tier == "gold").Map Option country = found.Bind(customer => customer.Address).Map(address => address.Country); // Some, the Option
flattened and then mapped Option<(string Name, string Town)> label = found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)); // Some, only when both are Option seen = found.Tap(customer => visits.Add(customer.Id)); // runs for Some, hands the option on +IEnumerable nicknames = customers.Values.Choose(customer => customer.Nickname); // "Annie", the nicknames of those who have one: a filter and a map in one +IEnumerable known = ids.Choose(id => customers.GetValueOrNone(id)); // the customers the ids find, the unknown ones dropped Option greeting = from customer in found @@ -75,7 +77,9 @@ Option greeting = `Map` changes the value and keeps absence; `Bind` takes a selector that is itself optional, so an `Option>` never appears; `Filter` turns a value that fails the predicate into `None`; `Zip` combines two to six options into one value when every one of them is `Some`; `Tap` runs an -action on a value and returns the option unchanged. `Select`, `SelectMany` and `Where` make query +action on a value and returns the option unchanged. `Choose` keeps, in order, the values a selector +answers `Some` for and drops the `None`s, a filter and a map in one, lazily as a LINQ operator is; over +a sequence of options, `Choose()` alone keeps the values they hold. `Select`, `SelectMany` and `Where` make query syntax work, and each `from` after the first sees the earlier ranges, as above. ## At the boundary @@ -125,6 +129,8 @@ Every public member, on `Option`, on the static class `Option`, and as extens | `SingleOrNone()`, `SingleOrNone(predicate)` | `IEnumerable`, `IQueryable` | the only element, or `None` for none; throws for more than one. A query is asked for two rows | | `ElementAtOrNone(index)` | `IEnumerable` (an `int` or an `Index`), `IQueryable` (an `int`) | the element at the index, or `None` past either end | | `GetValueOrNone(key)` | `IReadOnlyDictionary` | `Option` | +| `Choose(selector)` | `IEnumerable` | the values the selector answers `Some` for, in order and lazily: a filter and a map in one | +| `Choose()` | `IEnumerable>` | the values of the options that hold one, in order and lazily | | `IsSome()`, `IsNone()` | `Option` | `bool` | | `Match(onSome, onNone)` | `Option` | what the branch returns | | `TryGetValue(out value)` | `Option` | `bool`; `value` is non-null when true | diff --git a/docs/result.md b/docs/result.md index 07ff642..5742632 100644 --- a/docs/result.md +++ b/docs/result.md @@ -163,6 +163,18 @@ string stopped = refused.Traverse(checkout.ToLine).Match(list => $"{list.Count} argument order, and `Traverse` calls its selector for nothing after it. None of them collects errors: a fault type that is a list of your own is how several faults travel together. +An asynchronous operation runs one element at a time with `TraverseAsync`, each awaited before the +next starts, so nothing starts after a fault. With a cancellation token, the token is checked before +each operation and handed to it, and a cancellation propagates rather than becoming an error. +Operations that may all run at once are `Task.WhenAll` over their tasks and `Sequence()` over what +it answers. + +```csharp +Result, CheckoutFault> reserved = await carts.TraverseAsync(checkout.ReserveAsync); // one at a time, in order; nothing starts after a fault +Result charged = await totals.TraverseAsync(checkout.ChargeAsync); // a command per element, likewise +Result, CheckoutFault> found = await ids.TraverseAsync(checkout.LoadCartAsync, cancellationToken); // the token checked before each, and handed to it +``` + ## An exception as an outcome A call that throws has no way into a `Result` except a `try`/`catch` written by hand. `Result.Catch` @@ -222,6 +234,8 @@ extensions in the `CodoMetis.TypeKit` namespace. `T` and `TError` are `notnull` | `FirstOrError(predicate, error)`, `LastOrError(predicate, error)` | `IEnumerable`; `FirstOrError` on `IQueryable` too | the first or last match, or the error; on a query the provider runs the predicate, and `LastOrError` is a compile error | | `Traverse(selector)` | `IEnumerable` | `Result, TError>`, or `Result` for a command selector; stops at the first error | | `Traverse(selector)` | `Option` | `Result, TError>`: an optional input validated when present | +| `Traverse(selector)` | `Patch` | `Result, TError>`: a patch field validated when it carries a value; a clear or an absence passes through | +| `TraverseAsync(selector)`, `TraverseAsync(selector, cancellationToken)` | `IEnumerable` | as `Traverse`, one operation awaited at a time; nothing starts after an error, or once the token is cancelled | | `Transpose()` | `Option>`, `Result, TError>` | the one turned inside out: `None` is a success without a value, an error stays the error | | `Sequence()` | `IEnumerable>`, `IEnumerable>` | the list of values, or the first error | diff --git a/docs/testing.md b/docs/testing.md index 94711f9..12b1cb0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -55,6 +55,26 @@ Shouldly or whatever the test already uses. A failure names the error, which is to see, and never prints a value, as `Option` and `Result` never do. An uninitialized result fails every assertion, saying it is a default instance. +A request's `Patch` fields say which of them the request mentioned, and the assertions say the +same: `ShouldBeValue()` hands the value back, `ShouldBeNull()` is a clear, and `ShouldBeAbsent()` a +field the document left out. + +```csharp +public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); +``` + +```csharp +[Fact] +public void A_patch_says_what_the_request_mentioned() +{ + CodePatch patch = JsonSerializer.Deserialize("""{"note":null,"quantity":2}""", JsonSerializerOptions.Web)!; + + patch.Quantity.ShouldBeValue().ShouldBe(2); // the value, for the test's own assertions + patch.Note.ShouldBeNull(); // a clear + patch.Code.ShouldBeAbsent(); // not in the document +} +``` + ## Create as a table The rules are the value object's contract, and a table says all of them in one place: diff --git a/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs new file mode 100644 index 0000000..eeb89ef --- /dev/null +++ b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs @@ -0,0 +1,45 @@ +namespace CodoMetis.TypeKit.Testing; + +/// Assertions on a : which of its three states a field is in. +/// +/// hands the value back, so the test compares it with the +/// assertions it already uses. A failure never prints the value, as never does. +/// +public static class PatchAssertions +{ + /// The value of a patch whose field must have carried one. + /// The patch under test. + /// The value. + /// The field was null or not mentioned. + public static T ShouldBeValue(this Patch patch) => + patch.TryGetValue(out var value) + ? value + : throw new TypeKitAssertionException($"Expected a value, but the {Outcomes.Name>()} is {Describe(patch.State)}."); + + /// Fails unless the field was null: a clear. + /// The patch under test. + /// The field carried a value, which is not printed, or was not mentioned. + public static void ShouldBeNull(this Patch patch) + { + if (patch.State != PatchState.Null) + throw new TypeKitAssertionException($"Expected Null, a clear, but the {Outcomes.Name>()} {Was(patch.State)}."); + } + + /// Fails unless the field was not mentioned. + /// The patch under test. + /// The field carried a value, which is not printed, or was null. + public static void ShouldBeAbsent(this Patch patch) + { + if (patch.State != PatchState.Absent) + throw new TypeKitAssertionException($"Expected Absent, but the {Outcomes.Name>()} {Was(patch.State)}."); + } + + private static string Was(PatchState state) => state == PatchState.Value ? "holds a value (not printed)" : $"is {Describe(state)}"; + + private static string Describe(PatchState state) => state switch + { + PatchState.Null => "Null: the field was cleared", + PatchState.Absent => "Absent: the field was not mentioned", + _ => "a value", + }; +} diff --git a/src/CodoMetis.TypeKit.Testing/README.md b/src/CodoMetis.TypeKit.Testing/README.md index 2b181f0..e02da1a 100644 --- a/src/CodoMetis.TypeKit.Testing/README.md +++ b/src/CodoMetis.TypeKit.Testing/README.md @@ -1,7 +1,7 @@ # CodoMetis.TypeKit.Testing -Test assertions for the `Option` and `Result` of -[CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit). Neither type has a `.Value`, so a +Test assertions for the `Option`, `Result` and `Patch` of +[CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit). None of them has a `.Value`, so a test reaches the content through these, and fails with what the outcome was instead. ```bash @@ -19,13 +19,24 @@ Email.TryFrom("ops@example.com").ShouldBeSome(); Email.TryFrom("nobody").ShouldBeNone(); ``` -- **The content comes back.** `ShouldBeSome()`, `ShouldBeSuccess()` and `ShouldBeError()` return the - value or the error, so it is compared with whatever assertions the test already uses: xUnit's - `Assert`, Shouldly, or another. +A `Patch` field of a request says whether the request mentioned it, and the assertions say the +same; `OrderPatch` here has `Patch Name`, `Patch Nickname` and `Patch Quantity`: + +```csharp +OrderPatch patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; + +int quantity = patch.Quantity.ShouldBeValue(); // the value, handed back +patch.Nickname.ShouldBeNull(); // a clear +patch.Name.ShouldBeAbsent(); // not in the document +``` + +- **The content comes back.** `ShouldBeSome()`, `ShouldBeSuccess()`, `ShouldBeError()` and + `ShouldBeValue()` return the value or the error, so it is compared with whatever assertions the test + already uses: xUnit's `Assert`, Shouldly, or another. - **No test framework.** A failure is a `TypeKitAssertionException`, and every framework reports an exception from a test as its failure. The package depends on `CodoMetis.TypeKit` alone. - **A failure says what the outcome was**: `Expected a success, but the Result is the error NoAt.` It names the error, which is what a test needs to see, and never prints a value, as - `Option` and `Result` themselves never do. + `Option`, `Result` and `Patch` themselves never do. - **An uninitialized result fails every assertion**, saying it is a default instance, neither a success nor an error. diff --git a/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs b/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs index def3682..8106cf2 100644 --- a/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs +++ b/src/CodoMetis.TypeKit.Testing/TypeKitAssertionException.cs @@ -1,7 +1,7 @@ namespace CodoMetis.TypeKit.Testing; /// -/// An or a Result is not the outcome the test expected. Every test +/// An , a Result or a is not the outcome the test expected. Every test /// framework reports an exception from a test as its failure, so this package needs none of them. /// public sealed class TypeKitAssertionException : Exception diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index 41edb95..ed5f1c8 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -486,6 +486,22 @@ public Option ElementAtOrNone(Index index) var last = source.TakeLast(index.Value).ToList(); return last.Count == index.Value ? Some(last[0]) : None(); } + + /// + /// The values finds: for each element it answers with Some, + /// that value, in order, and nothing for a None. A filter and a map in one, as F#'s + /// choose, and lazy, as a LINQ operator is. + /// + /// Called with each element as the sequence is read. + /// The type of the values. + /// The values found, in order. + public IEnumerable Choose(Func> selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); + + return Chosen(source, selector); + } } /// The query. @@ -614,6 +630,20 @@ public Option GetValueOrNone(TKey key) } } + /// The options. + /// The type of the values. + extension(IEnumerable> options) where T : notnull + { + /// The values of the options that hold one, in order: a sequence of lookups to what was found. Lazy, as a LINQ operator is. + /// The values, in order. + public IEnumerable Choose() + { + ArgumentNullException.ThrowIfNull(options); + + return Chosen(options, static option => option); + } + } + /// The nullable value. /// The underlying value type. extension(in T? instance) where T : struct @@ -633,4 +663,13 @@ public Option GetValueOrNone(TKey key) [MethodImpl(MethodImplOptions.AggressiveInlining)] public Option ToOption() => instance is not null ? Some(instance) : None(); } + + /// The iterator behind both Choose, apart so the arguments are checked when the call is made, not when the sequence is read. + private static IEnumerable Chosen(IEnumerable source, Func> selector) where TResult : notnull + { + foreach (var element in source) + { + if (selector(element).TryGetValue(out var value)) yield return value; + } + } } diff --git a/src/CodoMetis.TypeKit/Patch.cs b/src/CodoMetis.TypeKit/Patch.cs index fc23689..63af587 100644 --- a/src/CodoMetis.TypeKit/Patch.cs +++ b/src/CodoMetis.TypeKit/Patch.cs @@ -121,6 +121,26 @@ public void IfValue(Action action) if (_state == PatchState.Value) action(_value!); } + /// + /// A patch of what makes of the value: a field that carried one + /// carries the produced value, a clear stays a clear, and an absent field stays absent. + /// + /// Called with the value when the field carried one. + /// The type of the produced value. + /// A patch in the same state. + /// is null, or returned null: a clear is the Null state, never a value. + public Patch Map(Func selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(selector); + + if (_state != PatchState.Value) return new Patch(default, _state); + + var produced = selector(_value!); + if (produced is null) throw ThrowHelper.NullPatchValue(nameof(selector)); + + return new Patch(produced, PatchState.Value); + } + /// The state only, never the value. public override string ToString() => $"Patch {{ State = {_state} }}"; } diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index fa6f12e..1c16c58 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -51,6 +51,8 @@ the provider for one element and let it run the predicate (a `WHERE` and a `LIMI compile error, since a query has no last element until it is ordered. `SingleOrNone()` answers the only element, `None` for none, and throws for more than one, as `SingleOrDefault` does, since many is a defect and not absence; `ElementAtOrNone(index)` answers the element at an index, `^1` included. +`Choose(selector)` keeps the values a selector answers `Some` for, in order and without the `None`s, a +filter and a map in one, and `Choose()` the values a sequence of options holds. An optional input that is validated when present is `Traverse`: `dto.Phone.ToOption().Traverse(Phone.Create)` is a `Result, PhoneFault>`, and `Transpose()` turns an `Option>` inside out, and a `Result>` back. @@ -159,7 +161,9 @@ Result, SkuFault> all = parsed.Sequence(); ``` `Zip` (two to six results), `Traverse` and `Sequence` stop at the first error, in argument or -sequence order, and `Traverse` calls nothing after it. None of them collects errors. +sequence order, and `Traverse` calls nothing after it. None of them collects errors. `TraverseAsync` +awaits one operation at a time, in order, and starts none after an error, or once the cancellation +token it is given is cancelled; operations that may all run at once are `Task.WhenAll` and `Sequence()`. The asynchronous steps take callbacks that return a `Task`, and wait for them: `.TapErrorAsync(fault => audit.RecordAsync(fault))` has recorded the fault when the chain's `await` @@ -404,7 +408,10 @@ order.Quantity = patch.Quantity.Match( Reading never refuses a null: the Null state is part of the wire format whatever `T` is, and whether a clear is accepted is the application's rule, said in `Match`. So a `Patch` over a struct is never needed, and a value object inside a patch keeps its own rule: `Patch` given a value -`Create` refuses is refused as anywhere else, the input not quoted. Writing an absent patch is +`Create` refuses is refused as anywhere else, the input not quoted. Where the refusal should be the +endpoint's own fault, the request carries a `Patch` and `Traverse` validates it: +`request.Email.Traverse(Email.Create)` is a `Result, EmailFault>`, a clear and an +absence passed through, and `Map` changes a value the same way. Writing an absent patch is refused, since a converter cannot leave a property out; a property that is written takes `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]`, and an absent patch is left out. @@ -490,7 +497,8 @@ there too. **Thrown:** `ArgumentNullException` from `Option.Some(null)`, `Result.Success(null)` or `Result.Error(null)`, from a `Map` or `MapError` whose selector returns null, from `FirstOrNone()` -over a null element, or for a null error given to `ToResult`, `Ensure` or `FirstOrError`. +over a null element, or for a null error given to `ToResult`, `Ensure` or `FirstOrError`. A +`Patch.Map` whose selector returns null throws the same: a clear is the Null state, never a value. **Why:** an option that reports a value always has one, and a result's value and error are never null. `notnull` is not checked at run time, so the factories check it. diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 8a23c48..0c6b2fb 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -989,6 +989,123 @@ public Result Traverse(Func> selector) where T return source.Select(selector).Sequence(); } + + /// + /// Applies an asynchronous operation that may fail to every element, one at a time and in + /// order, stopping at the first error: the next element's operation is not started after one + /// failed. Operations that may all run at once are Task.WhenAll over their tasks and + /// Sequence() over what it answers. + /// + /// Called with each element and awaited, until one fails. + /// The type of each produced value. + /// The type of the error. + /// A success with every produced value, in order, or the first error. + /// returned an uninitialized result. + public async Task, TError>> TraverseAsync(Func>> selector) + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); + + var values = source.TryGetNonEnumeratedCount(out var count) ? new List(count) : []; + + foreach (var element in source) + { + var result = await selector(element).ConfigureAwait(false); + if (!result.TryGetValue(out var value, out var error)) return Result, TError>.Error(error); + + values.Add(value); + } + + return Result, TError>.Success(values); + } + + /// + /// Applies an asynchronous operation that may fail to every element, one at a time and in + /// order, stopping at the first error, with a cancellation token handed to each operation and + /// checked before each: once cancelled, the next operation is not started and the + /// propagates, since a cancellation is not an error. + /// + /// Called with each element and the token, and awaited, until one fails. + /// Checked before each operation, and handed to it. + /// The type of each produced value. + /// The type of the error. + /// A success with every produced value, in order, or the first error. + /// returned an uninitialized result. + /// was cancelled before an operation started. + public async Task, TError>> TraverseAsync(Func>> selector, CancellationToken cancellationToken) + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); + + var values = source.TryGetNonEnumeratedCount(out var count) ? new List(count) : []; + + foreach (var element in source) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await selector(element, cancellationToken).ConfigureAwait(false); + if (!result.TryGetValue(out var value, out var error)) return Result, TError>.Error(error); + + values.Add(value); + } + + return Result, TError>.Success(values); + } + + /// + /// Applies an asynchronous command that may fail to every element, one at a time and in + /// order, stopping at the first error: the next element's command is not started after one + /// failed. + /// + /// Called with each element and awaited, until one fails. + /// The type of the error. + /// A success if every command succeeded, or the first error. + /// returned an uninitialized result. + public async Task> TraverseAsync(Func>> selector) where TError : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); + + foreach (var element in source) + { + var result = await selector(element).ConfigureAwait(false); + if (result.TryGetError(out var error)) return Result.Error(error); + } + + return Result.Success(); + } + + /// + /// Applies an asynchronous command that may fail to every element, one at a time and in + /// order, stopping at the first error, with a cancellation token handed to each command and + /// checked before each: once cancelled, the next command is not started and the + /// propagates, since a cancellation is not an error. + /// + /// Called with each element and the token, and awaited, until one fails. + /// Checked before each command, and handed to it. + /// The type of the error. + /// A success if every command succeeded, or the first error. + /// returned an uninitialized result. + /// was cancelled before a command started. + public async Task> TraverseAsync(Func>> selector, CancellationToken cancellationToken) where TError : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); + + foreach (var element in source) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await selector(element, cancellationToken).ConfigureAwait(false); + if (result.TryGetError(out var error)) return Result.Error(error); + } + + return Result.Success(); + } } /// The results. @@ -1104,6 +1221,34 @@ public Result, TError> Traverse(FuncA field of a merge patch, validated when it carries a value. + /// The type of the value. + extension(Patch patch) + { + /// + /// Applies an operation that may fail to the value, if the field carried one: a patch over + /// what the request sent is validated into a patch over the domain's type, + /// request.Email.Traverse(Email.Create), and a clear or an absent field passes through + /// as it is, since there is nothing to validate. + /// + /// Called with the value when the field carried one. + /// The type of the produced value. + /// The type of the error. + /// A success holding a patch in the same state, with the produced value when the field carried one, or the error. + /// returned an uninitialized result. + public Result, TError> Traverse(Func> selector) + where TResult : notnull + where TError : notnull + { + ArgumentNullException.ThrowIfNull(selector); + + if (!patch.TryGetValue(out var value)) + return Result, TError>.Success(patch.State == PatchState.Null ? Patch.Null() : Patch.Absent()); + + return selector(value).Map(static produced => Patch.Value(produced)); + } + } + /// /// A zip inspects every argument before the first error wins: an uninitialized result is a bug /// wherever it stands, and short-circuiting would let one after an error pass unnoticed. diff --git a/src/CodoMetis.TypeKit/ThrowHelper.cs b/src/CodoMetis.TypeKit/ThrowHelper.cs index a0f1582..a105e0e 100644 --- a/src/CodoMetis.TypeKit/ThrowHelper.cs +++ b/src/CodoMetis.TypeKit/ThrowHelper.cs @@ -13,6 +13,10 @@ internal static class ThrowHelper "An Option or a Result never holds null: an option that reports a value always has one, and a result's value " + "and error are never null. ToOption() turns a null into None." + Refusals.NullContent; + private const string NullPatchValueMessage = + "A patch never holds null as its value: a clear is the Null state, Patch.Null(), and a value is never null. " + + "Map's selector returned null for a value." + Refusals.NullContent; + /// /// A result is a bug at the site that produced it. Picking /// either branch would hand the caller a plausible answer, and for the error branch a fabricated @@ -28,4 +32,9 @@ internal static class ThrowHelper /// The parameter that was null. /// The exception, to throw. public static ArgumentNullException NullContent(string paramName) => new(paramName, NullContentMessage); + + /// What Patch<T>.Map throws for a selector that returned null. + /// The selector's parameter. + /// The exception, to throw. + public static ArgumentNullException NullPatchValue(string paramName) => new(paramName, NullPatchValueMessage); } diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs index 1221574..237f1be 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs @@ -9,6 +9,10 @@ namespace ReadmeSamples.Docs.MergePatches; +// sample: merge-patches/validate +public sealed record ContactRequest(Patch Email, Patch Floor); +// end sample + // sample: merge-patches/types public enum EmailFault { NoAt } @@ -102,6 +106,22 @@ public static void Refused() // end sample } + public static (Result, EmailFault> Refused, Result, EmailFault> Clear, Result, EmailFault> Untouched, Patch Tenfold) Validate() + { + // sample: merge-patches/validate-use + ContactRequest nobody = JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web)!; + ContactRequest cleared = JsonSerializer.Deserialize("""{"email":null}""", JsonSerializerOptions.Web)!; + ContactRequest moved = JsonSerializer.Deserialize("""{"floor":3}""", JsonSerializerOptions.Web)!; + + Result, EmailFault> refused = nobody.Email.Traverse(Email.Create); // Error: NoAt, the endpoint's own fault + Result, EmailFault> clear = cleared.Email.Traverse(Email.Create); // Success: Null, nothing to validate + Result, EmailFault> untouched = moved.Email.Traverse(Email.Create); // Success: Absent + Patch tenfold = moved.Floor.Map(floor => floor * 10); // Value: 30, a clear and an absence kept as they are + // end sample + + return (refused, clear, untouched, tenfold); + } + public static (string Clear, string Set) Write() { // sample: merge-patches/writing-use @@ -188,6 +208,18 @@ public void A_cleared_struct_field_is_null_not_zero() contact.Floor.ShouldBeNull(); } + [Fact] + public void Validating_a_patch_answers_what_the_comments_say() + { + var (refused, clear, untouched, tenfold) = Usage.Validate(); + string Validated(Result, EmailFault> result) => result.Match(patch => $"Success: {patch.State}", fault => $"Error: {fault}"); + + refused.ShouldBeShownIn(Page, Validated, "nobody.Email.Traverse(Email.Create)"); + clear.ShouldBeShownIn(Page, Validated, "cleared.Email.Traverse(Email.Create)"); + untouched.ShouldBeShownIn(Page, Validated, "moved.Email.Traverse(Email.Create)"); + tenfold.ShouldBeShownIn(Page, patch => patch.Match(value => $"Value: {value}", () => "Null", () => "Absent"), "moved.Floor.Map(floor => floor * 10)"); + } + [Fact] public void A_value_object_inside_a_patch_keeps_its_rule() { diff --git a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs index ee05ce2..7e54cb0 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs @@ -65,15 +65,19 @@ public sealed class Shop return (greeting, tier, limit, sum, known); } - public (Option Discount, Option Country, Option<(string Name, string Town)> Label, Option Seen, Option Greeting) Combine(Option found) + public (Option Discount, Option Country, Option<(string Name, string Town)> Label, Option Seen, Option Greeting, List Nicknames, List Known) Combine(Option found) { var visits = Visits; + var customers = Customers; + var ids = new List { Ann.Id, CustomerId.New() }; // sample: option/combine Option discount = found.Filter(customer => customer.Tier == "gold").Map(_ => 0.1m); // Some, Ann is gold Option country = found.Bind(customer => customer.Address).Map(address => address.Country); // Some, the Option
flattened and then mapped Option<(string Name, string Town)> label = found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)); // Some, only when both are Option seen = found.Tap(customer => visits.Add(customer.Id)); // runs for Some, hands the option on + IEnumerable nicknames = customers.Values.Choose(customer => customer.Nickname); // "Annie", the nicknames of those who have one: a filter and a map in one + IEnumerable known = ids.Choose(id => customers.GetValueOrNone(id)); // the customers the ids find, the unknown ones dropped Option greeting = from customer in found @@ -82,7 +86,7 @@ from address in customer.Address // a later from sees the earlier one select $"{customer.Name} in {address.Town}"; // Some // end sample - return (discount, country, label, seen, greeting); + return (discount, country, label, seen, greeting, nicknames.ToList(), known.ToList()); } // sample: option/boundary @@ -158,9 +162,11 @@ public void Combining_answers_what_the_comments_say() found.Bind(customer => customer.Address).Map(address => address.Country).ShouldBeShownIn(Readme, SomeOrNone); found.Zip(found.Bind(c => c.Address), (c, a) => (c.Name, a.Town)).ShouldBeShownIn(Readme, SomeOrNone); - var (discount, country, label, seen, greeting) = shop.Combine(found); + var (discount, country, label, seen, greeting, nicknames, known) = shop.Combine(found); (discount, country, label, seen, greeting).ShouldBe((Option.Some(0.1m), Option.Some("DE"), Option.Some(("Ann", "Berlin")), found, Option.Some("Ann in Berlin"))); shop.Visits.ShouldBe([shop.Ann.Id], "Tap ran once"); + nicknames.ShouldBe(["Annie"], "Choose kept the one nickname and dropped nothing else"); + known.ShouldBe([shop.Ann], "the unknown id was dropped"); } [Fact] diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs index 33385bd..07d04a4 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs @@ -54,6 +54,12 @@ public sealed class Checkout public Task> FindCartAsync(CartId id) => Task.FromResult(FindCart(id)); + public Task> LoadCartAsync(CartId id, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(FindCart(id)); + } + private bool InStock(Cart cart) => cart.Lines.All(line => stock.TryGetValue(line.Sku, out var item) && item.Stock >= line.Quantity); // sample: result/two-shapes @@ -159,6 +165,18 @@ public static (Result, CheckoutFault> Lines, Result, CheckoutFault> Reserved, Result Charged, Result, CheckoutFault> Found)> CollectAsync( + Checkout checkout, List carts, List totals, List ids, CancellationToken cancellationToken) + { + // sample: result/collect-async + Result, CheckoutFault> reserved = await carts.TraverseAsync(checkout.ReserveAsync); // one at a time, in order; nothing starts after a fault + Result charged = await totals.TraverseAsync(checkout.ChargeAsync); // a command per element, likewise + Result, CheckoutFault> found = await ids.TraverseAsync(checkout.LoadCartAsync, cancellationToken); // the token checked before each, and handed to it + // end sample + + return (reserved, charged, found); + } + public static async Task<(Result Url, Result Narrowed, Result AsFault, Result Written, Result Read)> Catching(string text, string path) { // sample: result/catch @@ -276,6 +294,20 @@ public void Collecting_answers_what_the_comments_say() noBulk.ShouldBe(CheckoutFault.OutOfStock); } + [Fact] + public async Task Collecting_asynchronously_runs_one_operation_at_a_time_and_stops_at_a_fault() + { + var checkout = new Checkout(); + + var (reserved, charged, found) = await Usage.CollectAsync(checkout, [checkout.Cart, checkout.Empty], [10m, 20m], [checkout.Cart.Id], TestContext.Current.CancellationToken); + + reserved.TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(CheckoutFault.CartEmpty); + ((bool)charged).ShouldBeTrue(); + found.TryGetValue(out var carts, out _).ShouldBeTrue(); + carts.ShouldBe([checkout.Cart]); + } + [Fact] public async Task Catch_answers_what_the_comments_say() { diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs index dc7e70d..980fe1c 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs @@ -33,6 +33,10 @@ public static Result Create(string value) public sealed record CodeRequest(ProductCode Code); // end sample +// sample: testing/code-patch +public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); +// end sample + /// The page's tests, run as written. public sealed class ProductCodeTests { @@ -52,6 +56,18 @@ public void A_code_is_created_or_refused() } // end sample + // sample: testing/patch + [Fact] + public void A_patch_says_what_the_request_mentioned() + { + CodePatch patch = JsonSerializer.Deserialize("""{"note":null,"quantity":2}""", JsonSerializerOptions.Web)!; + + patch.Quantity.ShouldBeValue().ShouldBe(2); // the value, for the test's own assertions + patch.Note.ShouldBeNull(); // a clear + patch.Code.ShouldBeAbsent(); // not in the document + } + // end sample + // sample: testing/table [Theory] [InlineData("ABC", null)] diff --git a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs index 1a195c7..3103272 100644 --- a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs @@ -2,11 +2,15 @@ using CodoMetis.TypeKit.Testing; // end sample +using System.Text.Json; using System.Text.RegularExpressions; +using CodoMetis.TypeKit; using ReadmeSamples.Root.QuickStart; namespace ReadmeSamples.Testing; +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); + public sealed partial class TestingSamples { private const string Readme = "src/CodoMetis.TypeKit.Testing/README.md"; @@ -28,6 +32,21 @@ public void The_assertions_pass_as_the_README_shows() fault.ShouldBe(EmailFault.Blank); } + /// The README's patch block, run: each line passes, or this test fails with its assertion. + [Fact] + public void The_patch_assertions_pass_as_the_README_shows() + { + // sample: CodoMetis.TypeKit.Testing/patch + OrderPatch patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; + + int quantity = patch.Quantity.ShouldBeValue(); // the value, handed back + patch.Nickname.ShouldBeNull(); // a clear + patch.Name.ShouldBeAbsent(); // not in the document + // end sample + + quantity.ShouldBe(2); + } + /// The failure message the README quotes, read from the README, is the one the assertion throws. [Fact] public void A_failure_says_what_the_README_quotes() diff --git a/test/CodoMetis.TypeKit.Testing.Tests/PatchAssertionTests.cs b/test/CodoMetis.TypeKit.Testing.Tests/PatchAssertionTests.cs new file mode 100644 index 0000000..5675ca4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Testing.Tests/PatchAssertionTests.cs @@ -0,0 +1,53 @@ +namespace CodoMetis.TypeKit.Testing.Tests; + +/// +/// The patch assertions hand back the value a field carried, and otherwise fail naming the state the +/// field was in, never the value. +/// +public sealed class PatchAssertionTests +{ + /// A value whose text would show if a message printed it. + private const string Secret = "s3cr3t-value"; + + [Fact] + public void A_value_is_handed_back() => Patch.Value(Secret).ShouldBeValue().ShouldBe(Secret); + + [Fact] + public void A_clear_is_no_value_and_is_named() => + Should.Throw(() => Patch.Null().ShouldBeValue()) + .Message.ShouldBe("Expected a value, but the Patch is Null: the field was cleared."); + + [Fact] + public void An_absent_field_is_no_value_and_is_named() => + Should.Throw(() => Patch.Absent().ShouldBeValue()) + .Message.ShouldBe("Expected a value, but the Patch is Absent: the field was not mentioned."); + + [Fact] + public void A_clear_is_Null() => Patch.Null().ShouldBeNull(); + + [Fact] + public void A_value_is_not_Null_and_is_not_printed() => + Should.Throw(() => Patch.Value(Secret).ShouldBeNull()) + .Message.ShouldBe("Expected Null, a clear, but the Patch holds a value (not printed)."); + + [Fact] + public void An_absent_field_is_not_Null() => + Should.Throw(() => Patch.Absent().ShouldBeNull()) + .Message.ShouldBe("Expected Null, a clear, but the Patch is Absent: the field was not mentioned."); + + [Fact] + public void An_absent_field_is_Absent() => Patch.Absent().ShouldBeAbsent(); + + [Fact] + public void A_value_is_not_Absent_and_is_not_printed() => + Should.Throw(() => Patch.Value(Secret).ShouldBeAbsent()) + .Message.ShouldBe("Expected Absent, but the Patch holds a value (not printed)."); + + [Fact] + public void A_clear_is_not_Absent() => + Should.Throw(() => Patch.Null().ShouldBeAbsent()) + .Message.ShouldBe("Expected Absent, but the Patch is Null: the field was cleared."); + + [Fact] + public void A_struct_patch_hands_back_its_value() => Patch.Value(3).ShouldBeValue().ShouldBe(3); +} diff --git a/test/CodoMetis.TypeKit.Tests/ChooseTests.cs b/test/CodoMetis.TypeKit.Tests/ChooseTests.cs new file mode 100644 index 0000000..377b5e6 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/ChooseTests.cs @@ -0,0 +1,62 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// Choose: the values of the options in a sequence, or of the options a selector answers, +/// in order and without the Nones. A filter and a map in one, lazy as a LINQ operator is. +/// +public sealed class ChooseTests +{ + private static Option Even(int n) => n % 2 == 0 ? Option.Some(n) : Option.None(); + + [Fact] + public void Choose_keeps_the_values_of_the_options_that_hold_one_in_order() + { + Option[] options = [Option.Some(1), Option.None(), Option.Some(3), Option.None(), Option.Some(5)]; + + options.Choose().ShouldBe([1, 3, 5]); + Array.Empty>().Choose().ShouldBeEmpty(); + new[] { Option.None() }.Choose().ShouldBeEmpty(); + } + + [Fact] + public void Choose_with_a_selector_keeps_what_it_answers_Some_for() + { + new[] { 1, 2, 3, 4, 5, 6 }.Choose(Even).ShouldBe([2, 4, 6]); + new[] { 1, 3 }.Choose(Even).ShouldBeEmpty(); + Array.Empty().Choose(Even).ShouldBeEmpty(); + + var customers = new Dictionary { [1] = "Ann", [3] = "Bo" }; + new[] { 1, 2, 3 }.Choose(id => customers.GetValueOrNone(id)).ShouldBe(["Ann", "Bo"]); + } + + /// A LINQ operator reads its source when it is enumerated, not when it is called, and only as far as the caller reads. + [Fact] + public void Choose_is_lazy() + { + var seen = new List(); + + IEnumerable Counted() + { + for (var n = 1; n <= 10; n++) + { + seen.Add(n); + yield return n; + } + } + + var chosen = Counted().Choose(Even); + seen.ShouldBeEmpty("nothing is read until the result is enumerated"); + + chosen.First().ShouldBe(2); + seen.ShouldBe([1, 2], "the source is read only as far as the first value found"); + } + + /// The arguments are checked when the call is made, as LINQ's are, not when the sequence is first read. + [Fact] + public void Choose_checks_its_arguments_before_the_sequence_is_read() + { + Should.Throw(() => ((IEnumerable)null!).Choose(Even)).ParamName.ShouldBe("source"); + Should.Throw(() => ((IEnumerable>)null!).Choose()).ParamName.ShouldBe("options"); + Should.Throw(() => new[] { 1 }.Choose((Func>)null!)).ParamName.ShouldBe("selector"); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index e73637b..2214843 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -99,6 +99,18 @@ public sealed class NullDelegateTests [() => Patch.Value(1).Match(x => x, () => 0, Null>()), () => Patch.Null().Match(x => x, () => 0, Null>()), () => Patch.Absent().Match(x => x, () => 0, Null>())], ["Patch.IfValue(Action): action"] = [() => { Patch.Value(1).IfValue(Null>()); return null; }, () => { Patch.Null().IfValue(Null>()); return null; }, () => { Patch.Absent().IfValue(Null>()); return null; }], ["Patch.IfPresent(Patch, Action): action"] = [() => { Patch.Value("a").IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], + ["Patch.Map(Func): selector"] = [() => Patch.Value(1).Map(Null>()), () => Patch.Null().Map(Null>()), () => Patch.Absent().Map(Null>())], + ["Result.Traverse(Patch, Func>): selector"] = + [() => Patch.Value(1).Traverse(Null>>()), () => Patch.Null().Traverse(Null>>()), () => Patch.Absent().Traverse(Null>>())], + ["Option.Choose(IEnumerable, Func>): selector"] = [() => new[] { 1 }.Choose(Null>>()), () => Array.Empty().Choose(Null>>())], + ["Result.TraverseAsync(IEnumerable, Func>>): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>()), () => Array.Empty().TraverseAsync(Null>>>())], + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>(), CancellationToken.None), () => Array.Empty().TraverseAsync(Null>>>(), CancellationToken.None)], + ["Result.TraverseAsync(IEnumerable, Func>>): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>()), () => Array.Empty().TraverseAsync(Null>>>())], + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken): selector"] = + [() => new[] { 1 }.TraverseAsync(Null>>>(), CancellationToken.None), () => Array.Empty().TraverseAsync(Null>>>(), CancellationToken.None)], ["Patch.IfPresent(Patch, Action>): action"] = [() => { Patch.Value(1).IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], // Catch has no branches: the delegate is checked before anything runs. Overloads that differ diff --git a/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs b/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs new file mode 100644 index 0000000..5d8e5b2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs @@ -0,0 +1,77 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// Map and Traverse over a : a field that carried a value is +/// mapped or validated, and a clear or an absent field passes through as it is. A patch over what the +/// request sent becomes a patch over the domain's type, with the validation's own fault. +/// +public sealed class PatchCombinationTests +{ + private static Result Positive(int n) => n > 0 ? n : Result.Error($"{n} is not positive"); + + private static string Show(Patch patch) => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); + + [Fact] + public void Map_changes_the_value_and_keeps_a_clear_and_an_absence() + { + Show(Patch.Value(2).Map(n => n * 10)).ShouldBe("Value(20)"); + Show(Patch.Null().Map(n => n * 10)).ShouldBe("Null"); + Show(Patch.Absent().Map(n => n * 10)).ShouldBe("Absent"); + + Show(Patch.Value("4").Map(int.Parse)).ShouldBe("Value(4)"); + } + + [Fact] + public void Map_runs_the_selector_for_a_value_only() + { + var calls = 0; + + Patch.Null().Map(n => ++calls); + Patch.Absent().Map(n => ++calls); + calls.ShouldBe(0); + + Patch.Value(1).Map(n => ++calls); + calls.ShouldBe(1); + } + + /// A clear is the Null state, never a null value: a selector that answers null is refused, as Option.Map's is. + [Fact] + public void Map_refuses_a_selector_that_returns_null() + { + var refusal = Should.Throw(() => Patch.Value(1).Map(_ => (string)null!)); + + refusal.ParamName.ShouldBe("selector"); + refusal.Message.ShouldContain("Patch.Null()"); + } + + [Fact] + public void Traverse_validates_a_value_and_keeps_its_fault() + { + Result, string> valid = Patch.Value(3).Traverse(Positive); + valid.Match(Show, error => error).ShouldBe("Value(3)"); + + Result, string> refused = Patch.Value(-3).Traverse(Positive); + refused.Match(Show, error => error).ShouldBe("-3 is not positive"); + } + + [Fact] + public void Traverse_passes_a_clear_and_an_absence_through_as_successes() + { + var calls = 0; + Result Counted(int n) { calls++; return Positive(n); } + + Patch.Null().Traverse(Counted).Match(Show, error => error).ShouldBe("Null"); + Patch.Absent().Traverse(Counted).Match(Show, error => error).ShouldBe("Absent"); + calls.ShouldBe(0, "there is nothing to validate"); + } + + /// The usual selector is a value object's Create, a method group over the wrapped type. + [Fact] + public void Traverse_takes_a_factory_as_a_method_group() + { + static Result Trimmed(string text) => text.Trim().Length > 0 ? text.Trim() : Result.Error("blank"); + + Patch.Value(" a ").Traverse(Trimmed).Match(Show, error => error).ShouldBe("Value(a)"); + Patch.Value(" ").Traverse(Trimmed).Match(Show, error => error).ShouldBe("blank"); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index d64a626..cfaa154 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -67,6 +67,7 @@ private enum Fault { TooLong } ["Option.Some(null)"] = (NullContent, () => Option.Some(null!)), ["Map to null"] = (NullContent, () => Option.Some("x").Map(_ => (string)null!)), + ["Patch Map to null"] = (NullContent, () => Patch.Value("x").Map(_ => (string)null!)), ["FirstOrNone over a null"] = (NullContent, () => new string[] { null! }.FirstOrNone()), ["ToResult(null)"] = (NullContent, () => Option.Some(1).ToResult((string)null!)), ["Result.Success(null)"] = (NullContent, () => Result.Success(null!)), diff --git a/test/CodoMetis.TypeKit.Tests/ResultTraverseAsyncTests.cs b/test/CodoMetis.TypeKit.Tests/ResultTraverseAsyncTests.cs new file mode 100644 index 0000000..ecea484 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/ResultTraverseAsyncTests.cs @@ -0,0 +1,136 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// TraverseAsync: an asynchronous operation that may fail, applied to every element one at a +/// time and in order, stopping at the first error, so the next operation is never started after one +/// failed. Operations that may all run at once are Task.WhenAll and Sequence(). +/// +public sealed class ResultTraverseAsyncTests +{ + private static Result Ok(int value) => Result.Success(value); + + private static Result Fail(string error) => Result.Error(error); + + [Fact] + public async Task TraverseAsync_awaits_each_operation_in_order_and_collects_the_values() + { + var order = new List(); + + async Task> Parse(string text) + { + order.Add($"start {text}"); + await Task.Yield(); + order.Add($"end {text}"); + return int.TryParse(text, out var n) ? Ok(n) : Fail(text); + } + + var parsed = await new[] { "1", "2", "3" }.TraverseAsync(Parse); + + parsed.Match(values => values, _ => []).ShouldBe([1, 2, 3]); + order.ShouldBe(["start 1", "end 1", "start 2", "end 2", "start 3", "end 3"], "one operation at a time, each finished before the next starts"); + } + + [Fact] + public async Task TraverseAsync_stops_before_the_next_operation_after_an_error() + { + var started = new List(); + + Task> Parse(string text) + { + started.Add(text); + return Task.FromResult(int.TryParse(text, out var n) ? Ok(n) : Fail(text)); + } + + var parsed = await new[] { "1", "x", "y", "4" }.TraverseAsync(Parse); + + parsed.Match(_ => "", error => error).ShouldBe("x"); + started.ShouldBe(["1", "x"]); + } + + [Fact] + public async Task TraverseAsync_with_a_command_runs_until_one_fails() + { + var sent = new List(); + + Task> Send(int n) + { + if (n == 3) return Task.FromResult(Result.Error($"refused {n}")); + + sent.Add(n); + return Task.FromResult(Result.Success()); + } + + (await new[] { 1, 2, 3, 4 }.TraverseAsync(Send)).TryGetError(out var error).ShouldBeTrue(); + error.ShouldBe("refused 3"); + sent.ShouldBe([1, 2]); + + ((bool)await new[] { 1, 2 }.TraverseAsync(Send)).ShouldBeTrue(); + ((bool)await Array.Empty().TraverseAsync(Send)).ShouldBeTrue(); + } + + [Fact] + public async Task TraverseAsync_over_an_empty_sequence_is_an_empty_success() + { + var values = await Array.Empty().TraverseAsync(text => Task.FromResult(Ok(text.Length))); + + values.Match(list => list.Count, _ => -1).ShouldBe(0); + } + + /// + /// The token is handed to each operation and checked before each: once cancelled, the next + /// operation is not started, and the cancellation propagates rather than becoming an error. + /// + [Fact] + public async Task TraverseAsync_hands_the_token_on_and_stops_once_cancelled() + { + using var cancellation = new CancellationTokenSource(); + var started = new List(); + + Task> Load(int n, CancellationToken token) + { + token.ShouldBe(cancellation.Token, "the operation receives the caller's token"); + started.Add(n); + if (n == 2) cancellation.Cancel(); + return Task.FromResult(Ok(n)); + } + + await Should.ThrowAsync(() => new[] { 1, 2, 3 }.TraverseAsync(Load, cancellation.Token)); + started.ShouldBe([1, 2], "the third operation is not started after the token was cancelled"); + + using var commandCancellation = new CancellationTokenSource(); + + Task> Send(int n, CancellationToken token) + { + token.ShouldBe(commandCancellation.Token); + started.Add(n); + if (n == 2) commandCancellation.Cancel(); + return Task.FromResult(Result.Success()); + } + + started.Clear(); + await Should.ThrowAsync(() => new[] { 1, 2, 3 }.TraverseAsync(Send, commandCancellation.Token)); + started.ShouldBe([1, 2]); + } + + [Fact] + public async Task TraverseAsync_with_a_token_that_is_not_cancelled_runs_every_operation() + { + var values = await new[] { 1, 2 }.TraverseAsync((n, _) => Task.FromResult(Ok(n * 10)), CancellationToken.None); + values.Match(list => list, _ => []).ShouldBe([10, 20]); + + var command = await new[] { 1, 2 }.TraverseAsync((_, _) => Task.FromResult(Result.Success()), CancellationToken.None); + ((bool)command).ShouldBeTrue(); + + var refused = await new[] { 1, 2 }.TraverseAsync((n, _) => Task.FromResult(n == 2 ? Fail("two") : Ok(n)), CancellationToken.None); + refused.Match(_ => "", error => error).ShouldBe("two"); + } + + [Fact] + public async Task TraverseAsync_with_a_cancelled_token_starts_nothing() + { + var started = 0; + + await Should.ThrowAsync(() => new[] { 1 }.TraverseAsync((n, _) => { started++; return Task.FromResult(Ok(n)); }, new CancellationToken(canceled: true))); + started.ShouldBe(0); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs b/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs index 2f08dbd..8c7e732 100644 --- a/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/UninitializedResultTests.cs @@ -126,6 +126,11 @@ public sealed class UninitializedResultTests ["Traverse(IEnumerable, Func>)"] = () => Task.FromResult(new[] { 1 }.Traverse(_ => default(Result))), ["Traverse(IEnumerable, Func>)"] = () => Task.FromResult(new[] { 1 }.Traverse(_ => default(Result))), + ["Traverse(Patch, Func>)"] = () => Task.FromResult(Patch.Value(1).Traverse(_ => default(Result))), + ["TraverseAsync(IEnumerable, Func>>)"] = () => new[] { 1 }.TraverseAsync(_ => Task.FromResult(default(Result))), + ["TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = () => new[] { 1 }.TraverseAsync((_, _) => Task.FromResult(default(Result)), CancellationToken.None), + ["TraverseAsync(IEnumerable, Func>>)"] = () => new[] { 1 }.TraverseAsync(_ => Task.FromResult(default(Result))), + ["TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = () => new[] { 1 }.TraverseAsync((_, _) => Task.FromResult(default(Result)), CancellationToken.None), ["Sequence(IEnumerable>)"] = () => Task.FromResult(new[] { Value, default }.Sequence()), ["Sequence(IEnumerable>)"] = () => Task.FromResult(new[] { Result.Success(), default }.Sequence()), }; From a8c87ee5430686f79c5076eac62f557b521cd827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 19:37:26 +0200 Subject: [PATCH 035/147] feat: a closed set parses ignoring case when asked, and CMTK1021 refuses names that differ only in case `Parse(name, ignoreCase)` and `TryParse(name, ignoreCase, out member)` join the ordinal pair, opted into at the call for a header or a query string as a client typed it; JSON, the column, binding and IParsable stay ordinal, since what is stored or sent is the name as it is. With the overload, two members whose names differ only in case are one name to the parse, so CMTK1021 refuses them where it refused the first-letter case that gave Match one parameter twice; `Express` and `EXPRESS` were generated before, and the ignore-case parse would have answered the first declared. A hand-written overload of either signature is CMTK1011. Seeded: with the old grouping and without the overloads in the hand-written check, the case-variant set and both hand-written overloads go unreported. Co-Authored-By: Claude Fable 5.1 --- docs/closed-sets.md | 9 ++++- .../ClosedSetAspect.Templates.cs | 37 +++++++++++++++++++ .../ClosedSetAspect.cs | 30 ++++++++++++--- .../ClosedSetTypes.cs | 5 ++- src/CodoMetis.TypeKit.Generators/README.md | 13 +++++-- .../BuildOutcomeTests.cs | 19 ++++++++-- .../ClosedSetTests.cs | 36 +++++++++++++++++- .../GeneratedSurface.verified.txt | 16 ++++++++ .../Docs/ClosedSetsSamples.cs | 8 ++-- 9 files changed, 152 insertions(+), 21 deletions(-) diff --git a/docs/closed-sets.md b/docs/closed-sets.md index 8cc4897..a27c6f1 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -38,12 +38,17 @@ string qualified = Shipping.Express.QualifiedName; // "Shipping. string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons bool known = Shipping.TryParse("Drone", out _); // false, names compare ordinally +bool typed = Shipping.TryParse("express", ignoreCase: true, out _); // true, when asked: a header or a query string as a client typed it Shipping.Parse("Drone"); // FormatException naming Shipping and member, never the name ``` `All` holds the members in declaration order, read-only: no cast to `IList` or an array writes it. `QualifiedName` keeps two sets that share a member name apart in one log or one client. `ToString()` is the name, unless the set writes its own, which -is kept, for a label, while the name stays what JSON, parsing and the column use. +is kept, for a label, while the name stays what JSON, parsing and the column use. `Parse` and +`TryParse` compare names ordinally; with `ignoreCase: true` they accept any casing, for what a client +typed, while JSON, the column, binding and `IParsable` stay ordinal, since what is stored or sent is +the name as it is. Two members whose names differ only in case are refused (CMTK1021), so there is one +member to find. ## Deciding by member @@ -164,7 +169,7 @@ A declaration they cannot complete fails the build, naming it; the generators' | is generic, or nested in a generic type | CMTK1018 | | has a member not created with `new` where it is declared: `Legacy = Open` would give one instance two names | CMTK1019 | | is a record: `with` would copy a member into an instance that is none | CMTK1020 | -| has a member named as a generated member, or two whose `Match` parameters coincide (`Open`, `open`) | CMTK1021 | +| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), one name to `Match`'s parameters and to `TryParse(name, ignoreCase: true)` | CMTK1021 | | writes a generated member by hand, `ToString()` apart | CMTK1011 | | is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs index 3e962e8..b904e88 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -77,6 +77,29 @@ public static bool TryParseTemplate([NotNullWhen(true)] string? name, [NotNullWh return false; } + /// + /// The member of that name, compared ordinally, or ignoring case when asked: a header or a query + /// string typed by a client. Two members whose names differ only in case are refused where the + /// set is declared (CMTK1021), so there is one member to find. + /// + [Template] + public static bool TryParseIgnoreCaseTemplate([NotNullWhen(true)] string? name, bool ignoreCase, [NotNullWhen(true)] out dynamic? member) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + foreach (var candidate in args.Members) + { + if (string.Equals(name, candidate.Name, comparison)) + { + member = candidate.Value!; + return true; + } + } + + member = null; + return false; + } + /// A naming the set, never the input. [Template] public static dynamic? ParseTemplate(string? name) @@ -90,6 +113,20 @@ public static bool TryParseTemplate([NotNullWhen(true)] string? name, [NotNullWh throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); } + /// The member of that name, compared ordinally or ignoring case when asked; a naming the set, never the input. + [Template] + public static dynamic? ParseIgnoreCaseTemplate(string? name, bool ignoreCase) + { + var args = (ClosedSetArguments)meta.Tags.Source!; + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + foreach (var candidate in args.Members) + { + if (string.Equals(name, candidate.Name, comparison)) return candidate.Value; + } + + throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); + } + /// IParsable<TSelf>.Parse: the set's own Parse; the provider means nothing to a name. [Template] public static dynamic? ParsableParseTemplate(string s, IFormatProvider? provider) diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index b51f512..a5206ee 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -20,8 +20,8 @@ internal sealed class ClosedSetArguments } /// -/// Generates a closed set: Name, QualifiedName, All, Match, TryParse, -/// Parse, IParsable<TSelf> explicitly, ToString() where none is written by +/// Generates a closed set: Name, QualifiedName, All, Match, TryParse +/// and Parse, each ordinal and each with an ignoreCase overload, IParsable<TSelf> explicitly, ToString() where none is written by /// hand, the JSON converter, the type converter, the discovery attribute, and a private constructor /// where the type declares none. Applied by . /// @@ -60,15 +60,16 @@ public override void BuildAspect(IAspectBuilder builder) problems.Add(ClosedSetMemberNotAField.WithArguments((target, property.Name, "a static property"))); // A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and - // TryParse, LAMA0611 beside ToString), and two members whose names differ only in their first - // letter's case gave Match one parameter name twice (LAMA0611, CS0100). + // TryParse, LAMA0611 beside ToString). Two members whose names differ only in case are one + // name to TryParse(name, ignoreCase: true), and where the difference is the first letter's + // they gave Match one parameter name twice (LAMA0611, CS0100). var generatedNames = ClosedSetTypes.GeneratedNames(target); foreach (var member in members.Where(member => generatedNames.Contains(member.Name))) problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, "has the name of a member the generators introduce"))); - foreach (var shared in members.GroupBy(ClosedSetTypes.ParameterName).Where(group => group.Count() > 1)) + foreach (var shared in members.GroupBy(member => member.Name, StringComparer.OrdinalIgnoreCase).Where(group => group.Count() > 1)) { foreach (var member in shared.Skip(1)) - problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, $"gives Match the parameter '{shared.Key}', as '{target.Name}.{shared.First().Name}' does"))); + problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, $"differs from '{target.Name}.{shared.First().Name}' only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart"))); } // A hand-written member the generators introduce failed the aspect with a Metalama error that @@ -147,6 +148,23 @@ public override void BuildAspect(IAspectBuilder builder) m.AddAttribute(CodeAnnotations.CompilerGenerated); }); + // The same two with ignoreCase, opted into at the call: JSON, the column, binding and + // IParsable stay ordinal, since what is stored or sent is the name as it is. + builder.IntroduceMethod(nameof(TryParseIgnoreCaseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "TryParse"; + m.Accessibility = Accessibility.Public; + m.Parameters[2].Type = target.ToNullable(); + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + builder.IntroduceMethod(nameof(ParseIgnoreCaseTemplate), IntroductionScope.Static, OverrideStrategy.Fail, m => + { + m.Name = "Parse"; + m.Accessibility = Accessibility.Public; + m.ReturnType = target; + m.AddAttribute(CodeAnnotations.CompilerGenerated); + }); + // IParsable, explicitly: Parse(string) and TryParse(string, out) are the set's own // surface, and the provider means nothing to a name. Generic code calling T.Parse reaches it. var parsable = builder.ImplementInterface(TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(target)); diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index 1b2a9d8..10b7907 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -153,13 +153,14 @@ private static string WithoutComments(string text) public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) { var text = TypeFactory.GetType(SpecialType.String); + var boolean = TypeFactory.GetType(SpecialType.Boolean); var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); return [ .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(set)]).Where(declared => !memberNames.Contains(declared)), - .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && SameParameters(method, text)).Select(ValueObjectDeclaration.Describe), - .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && SameParameters(method, text, set)).Select(ValueObjectDeclaration.Describe), + .. 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), diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index fe7dca7..2a837e0 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -212,8 +212,9 @@ public sealed partial class Tier : IClosedSet The generators add `Name` from the field's name, `QualifiedName` (`OrderStatus.Open`), `All` in declaration order and read-only, `Match` with one required delegate per member named after it, `TryParse` and -`Parse` by name, `ToString()`, a JSON converter that writes the name and reads the member itself -back, and a private parameterless constructor where the type declares none: +`Parse` by name, each ordinal or ignoring case when asked, `ToString()`, a JSON converter that +writes the name and reads the member itself back, and a private parameterless constructor where the +type declares none: ```csharp var label = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); // every member, or it does not compile @@ -228,6 +229,9 @@ position would follow the declaration order and swap branches when two members a analyzer requires every argument named (CMTK0012), and its code fix names them. `Match` checks every delegate for null before it picks one. `Parse` and the JSON converter refuse an unknown name with an exception naming the set, never the input, and a set is a dictionary key in JSON by its name too. +`Parse(name, ignoreCase: true)` and `TryParse(name, ignoreCase: true, out member)` accept any casing, +for a header or a query string as a client typed it; JSON, the type converter, binding and +`IParsable` stay ordinal, since what is stored or sent is the name as it is. `IParsable` is implemented explicitly, for generic code that parses through `T.Parse`, and a type converter reads and writes the name, so the configuration binder, MVC and anything else that reads a string through `TypeDescriptor` bind a set. The binder's source generator reads no type @@ -240,7 +244,8 @@ and the ASP.NET Core satellite answers it by kind. Each member is initialized with `new` where it is declared (CMTK1019), and the constructors are private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated -member, nor share its `Match` parameter with another (`Open` and `open`), CMTK1021. A `ToString()` +member, nor differ from another's only in case (`Open` and `open`), which `Match`'s parameters and +`TryParse(name, ignoreCase: true)` cannot tell apart, CMTK1021. A `ToString()` written by hand is kept, and the name is still what JSON, parsing and the column use; so is an overload of a generated method with another signature, such as `Parse(int rank)`. Any other generated member written by hand is CMTK1011. An instance the set's own code creates beside its members has no name, so reading its name throws rather than write a @@ -348,7 +353,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1018 | A closed set that is generic or nested in a generic type. | | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | -| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose `Match` parameter, its name with the first letter lower-cased, another member's already is. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose name differs from another member's only in case: one name to `Match`'s parameters, which lower-case the first letter, and to `TryParse(name, ignoreCase: true)`. | The analyzers that come with the base package add CMTK0001 to CMTK0012; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 9cd5abd..c0f9aa8 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -126,18 +126,25 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1021", "SetWithMemberNamedToString.ToString")] [InlineData("CMTK1021", "SetWithMemberNamedAll.All")] [InlineData("CMTK1021", "SetWithCasedMembers.open")] + [InlineData("CMTK1021", "SetWithCaseVariants.EXPRESS")] + [InlineData("CMTK1011", "SetWithIgnoreCaseTryParse")] + [InlineData("CMTK1011", "SetWithIgnoreCaseParse")] public void A_declaration_that_cannot_be_generated_is_an_error(string id, string type) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.Contains($"'{type}"), $"{id} on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); /// /// A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and - /// TryParse, LAMA0611 beside ToString), or was CMTK1011 telling the author to remove it; two members - /// whose names differ in their first letter's case gave Match one parameter twice (LAMA0611, CS0100). + /// TryParse, LAMA0611 beside ToString), or was CMTK1011 telling the author to remove it. Two members + /// whose names differ only in case are one name to TryParse(name, ignoreCase: true); where + /// the difference is the first letter's, they also gave Match one parameter twice (LAMA0611, CS0100), + /// and where it is elsewhere (Express, EXPRESS) the set was generated and the + /// ignore-case parse found the first declared. /// [Theory] [InlineData("SetWithMemberNamedParse.Parse", "has the name of a member the generators introduce")] [InlineData("SetWithMemberNamedAll.All", "has the name of a member the generators introduce")] - [InlineData("SetWithCasedMembers.open", "gives Match the parameter 'open', as 'SetWithCasedMembers.Open' does")] + [InlineData("SetWithCasedMembers.open", "differs from 'SetWithCasedMembers.Open' only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart")] + [InlineData("SetWithCaseVariants.EXPRESS", "differs from 'SetWithCaseVariants.Express' only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart")] public void A_member_name_the_generators_take_is_refused_with_the_reason(string member, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1021" && error.Message.StartsWith($"'{member}' {reason}, so ") && error.Message.Contains("rename the member"), consumer.Output); @@ -492,6 +499,12 @@ public sealed partial class SetWithoutMembers : IClosedSet { public sealed partial class SetWithCasedMembers : IClosedSet { public static readonly SetWithCasedMembers Open = new(); public static readonly SetWithCasedMembers open = new(); } + public sealed partial class SetWithCaseVariants : IClosedSet { public static readonly SetWithCaseVariants Express = new(); public static readonly SetWithCaseVariants EXPRESS = new(); } + + public sealed partial class SetWithIgnoreCaseTryParse : IClosedSet { public static readonly SetWithIgnoreCaseTryParse A = new(); public static bool TryParse(string? name, bool ignoreCase, out SetWithIgnoreCaseTryParse? member) { member = A; return true; } } + + public sealed partial class SetWithIgnoreCaseParse : IClosedSet { public static readonly SetWithIgnoreCaseParse A = new(); public static SetWithIgnoreCaseParse Parse(string name, bool ignoreCase) => A; } + // Generated; the calls below are CMTK0012's, in the project that declares the set. public sealed partial class SetToMatch : IClosedSet { public static readonly SetToMatch New = new(); public static readonly SetToMatch Open = new(); } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 4df2ab9..9b86610 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -90,6 +90,39 @@ public void TryParse_and_Parse_find_a_member_by_name_and_refuse_without_quoting( refusal.Message.ShouldContain("#a-closed-set-has-no-member-of-that-name"); } + /// + /// A header or a query string a client typed: the ordinal parse stays the default, and the + /// ignore-case one is opted into at the call. Unknown names are refused as the ordinal one refuses them. + /// + [Fact] + public void TryParse_and_Parse_ignore_case_when_asked() + { + ProbeStatus.TryParse("shipped", ignoreCase: true, out var shipped).ShouldBeTrue(); + ReferenceEquals(shipped, ProbeStatus.Shipped).ShouldBeTrue(); + ProbeStatus.TryParse("SHIPPED", ignoreCase: false, out _).ShouldBeFalse("ordinal when not asked"); + ProbeStatus.TryParse("shipped", ignoreCase: true, out _).ShouldBeTrue(); + ProbeStatus.TryParse("Shipped", ignoreCase: false, out _).ShouldBeTrue(); + ProbeStatus.TryParse("drone", ignoreCase: true, out _).ShouldBeFalse(); + ProbeStatus.TryParse(null, ignoreCase: true, out _).ShouldBeFalse(); + + ReferenceEquals(ProbeStatus.Parse("OPEN", ignoreCase: true), ProbeStatus.Open).ShouldBeTrue(); + ReferenceEquals(ProbeStatus.Parse("Open", ignoreCase: false), ProbeStatus.Open).ShouldBeTrue(); + Should.Throw(() => ProbeStatus.Parse("OPEN", ignoreCase: false)); + + var refusal = Should.Throw(() => ProbeStatus.Parse("nope", ignoreCase: true)); + refusal.Message.ShouldContain("ProbeStatus"); + refusal.Message.ShouldNotContain("nope"); + } + + /// What is stored or sent is the name as it is: JSON, the type converter and IParsable stay ordinal. + [Fact] + public void JSON_the_type_converter_and_IParsable_stay_ordinal() + { + Should.Throw(() => JsonSerializer.Deserialize("\"shipped\"")); + Should.Throw(() => System.ComponentModel.TypeDescriptor.GetConverter(typeof(ProbeStatus)).ConvertFromInvariantString("shipped")); + TryParseAs("shipped", out _).ShouldBeFalse(); + } + [Fact] public void JSON_is_the_name_and_reads_the_member_itself_back() { @@ -271,7 +304,8 @@ public void Generic_code_parses_a_set_through_IParsable() refusal.Message.ShouldNotContain("Nope"); Should.Throw(() => ParseAs(null!)); - typeof(ProbeStatus).GetMethods().Where(method => method.Name == "Parse").ShouldHaveSingleItem("the provider overload is explicit"); + typeof(ProbeStatus).GetMethods().Where(method => method.Name == "Parse").Select(method => method.GetParameters().Length) + .ShouldBe([1, 2], "the set's own Parse(name) and Parse(name, ignoreCase); the provider overload is explicit"); } /// diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index d8b4d68..607feaa 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -619,6 +619,8 @@ ProbeKeyword nested ProbeKeywordTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeKeyword + static Parse(String, Boolean) : ProbeKeyword + static TryParse(String, Boolean, out ProbeKeyword) : Boolean static TryParse(String, out ProbeKeyword) : Boolean ProbeKindlessFault Kind : FaultKind @@ -638,6 +640,8 @@ ProbeKindlessFault nested ProbeKindlessFaultTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeKindlessFault + static Parse(String, Boolean) : ProbeKindlessFault + static TryParse(String, Boolean, out ProbeKindlessFault) : Boolean static TryParse(String, out ProbeKindlessFault) : Boolean ProbeLabel $() : ProbeLabel @@ -693,6 +697,8 @@ ProbeLabelled nested ProbeLabelledTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeLabelled + static Parse(String, Boolean) : ProbeLabelled + static TryParse(String, Boolean, out ProbeLabelled) : Boolean static TryParse(String, out ProbeLabelled) : Boolean ProbeLevel Match(Func, Func) : TResult @@ -709,6 +715,8 @@ ProbeLevel nested ProbeLevelTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeLevel + static Parse(String, Boolean) : ProbeLevel + static TryParse(String, Boolean, out ProbeLevel) : Boolean static TryParse(String, out ProbeLevel) : Boolean static Unlisted() : ProbeLevel ProbeLocalDate @@ -855,6 +863,8 @@ ProbeOrderFault nested ProbeOrderFaultTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeOrderFault + static Parse(String, Boolean) : ProbeOrderFault + static TryParse(String, Boolean, out ProbeOrderFault) : Boolean static TryParse(String, out ProbeOrderFault) : Boolean ProbeOverride CompareTo(Object) : Int32 @@ -1013,6 +1023,8 @@ ProbePriority static All : IReadOnlyList static Parse(Int32) : ProbePriority static Parse(String) : ProbePriority + static Parse(String, Boolean) : ProbePriority + static TryParse(String, Boolean, out ProbePriority) : Boolean static TryParse(String, out ProbePriority) : Boolean ProbeRatio CompareTo(Object) : Int32 @@ -1176,6 +1188,8 @@ ProbeStatus nested ProbeStatusTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeStatus + static Parse(String, Boolean) : ProbeStatus + static TryParse(String, Boolean, out ProbeStatus) : Boolean static TryParse(String, out ProbeStatus) : Boolean ProbeTier Match(Func, Func) : TResult @@ -1194,6 +1208,8 @@ ProbeTier nested ProbeTierTypeConverter : TypeConverter static All : IReadOnlyList static Parse(String) : ProbeTier + static Parse(String, Boolean) : ProbeTier + static TryParse(String, Boolean, out ProbeTier) : Boolean static TryParse(String, out ProbeTier) : Boolean ProbeTime CompareTo(Object) : Int32 diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs index f2b23b1..85ac601 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs @@ -56,7 +56,7 @@ public sealed class CheckoutOptions public static class Usage { - public static (string Name, string Qualified, string All, bool Same, bool Known) Members() + public static (string Name, string Qualified, string All, bool Same, bool Known, bool Typed) Members() { // sample: closed-sets/members string name = Shipping.Express.Name; // "Express" @@ -64,9 +64,10 @@ public static (string Name, string Qualified, string All, bool Same, bool Known) string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons bool known = Shipping.TryParse("Drone", out _); // false, names compare ordinally + bool typed = Shipping.TryParse("express", ignoreCase: true, out _); // true, when asked: a header or a query string as a client typed it // end sample - return (name, qualified, all, same, known); + return (name, qualified, all, same, known, typed); } /// The block's last line, which throws: the same region, apart, so the lines above can run. @@ -132,13 +133,14 @@ public sealed class ClosedSetsTests [Fact] public void A_set_answers_what_the_comments_say() { - var (name, qualified, all, same, known) = Usage.Members(); + var (name, qualified, all, same, known, typed) = Usage.Members(); name.ShouldBeShownIn(Page, Quoted, "Shipping.Express.Name"); qualified.ShouldBeShownIn(Page, Quoted, "Shipping.Express.QualifiedName"); all.ShouldBeShownIn(Page, Quoted, "string.Join(\" \", Shipping.All.Select(s => s.Name))"); same.ShouldBeShownIn(Page, Lower, "Shipping.Parse(\"Express\") == Shipping.Express"); known.ShouldBeShownIn(Page, Lower, "Shipping.TryParse(\"Drone\", out _)"); + typed.ShouldBeShownIn(Page, Lower, "Shipping.TryParse(\"express\", ignoreCase: true, out _)"); SampleOutputs.ShouldRefuseAsShownIn(() => Shipping.Parse("Drone"), Page, input: "Drone"); Should.Throw(Usage.ParseRefused); From 2de3c9b4d878581618bfcad69c13f3f744002ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 19:37:26 +0200 Subject: [PATCH 036/147] docs: the README knows 2.0.0, and compares closed sets with the smart-enum libraries The status names 2.0.0 and points at the migration guide, the comparison tables name TypeKit 2.0.0, the pitch declares a closed set beside the value objects and names Patch, Result.Catch, the text guards and the constraint attributes, and a "Closed sets in detail" table compares against Ardalis.SmartEnum 8.2.0, Intellenum 2.0.1 and Thinktecture.Runtime.Extensions 10.5.1, read from their source and nuspecs on 2026-10-13, with a row that says a TypeKit member's wire name is its field's. The changelog and the plan (step 9b, decision 51) record the review's picks. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 25 ++++++-- README.md | 60 +++++++++++++++---- design/plan.md | 37 ++++++++++++ .../Root/IntroSamples.cs | 10 ++++ 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe661b9..5742dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,9 @@ this release adds, so nothing that built against 1.5.1 meets them. by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static readonly` field, with constant data through a private constructor where the members carry some. The generators add `Name`, `QualifiedName`, `All` in declaration order and read-only, `Match` with one required delegate - per member, `TryParse` and `Parse` by name, `ToString()`, a JSON converter that writes the name and + per member, `TryParse` and `Parse` by name, each with an `ignoreCase` overload for a header or a + query string as a client typed it while JSON, the column, binding and `IParsable` stay ordinal, + `ToString()`, a JSON converter that writes the name and reads the member itself back, a private constructor where none is declared, and the attribute the satellites read. CMTK1013 to CMTK1019 refuse a declaration that is not partial, not sealed, without members, with a constructor that is not private, with another public static member of its own type, @@ -45,8 +47,9 @@ this release adds, so nothing that built against 1.5.1 meets them. signature; any other generated member written by hand is CMTK1011. An instance the set's own code makes beside its members has no name, and reading it throws rather than write a null. CMTK0002 covers a closed set without the generators. - CMTK1021 refuses a member named as a generated member, or one whose `Match` parameter another - member's already is (`Open` and `open`). `Match` checks every delegate for null before it picks one, + CMTK1021 refuses a member named as a generated member, or two whose names differ only in case + (`Open` and `open`, `Express` and `EXPRESS`), one name to `Match`'s parameters and to + `TryParse(name, ignoreCase: true)`. `Match` checks every delegate for null before it picks one, and a set is a JSON dictionary key by its name. `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name, in a primitive collection and a JSON column too, through a compiled model and under Native AOT @@ -61,7 +64,11 @@ this release adds, so nothing that built against 1.5.1 meets them. absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never refuses a null; a value object inside a patch keeps its own rule. `Match`, `TryGetValue`, `IfValue`, `IfPresent` and `ToOption` apply it without reflection; `IfPresent` hands a clear over as null, a - `T?` over a struct too, never as `default(T)`. Writing an absent patch is refused, and + `T?` over a struct too, never as `default(T)`. `Map` changes a value and keeps a clear and an + absence, and `Traverse` validates a value with a `Create`'s own fault, + `request.Email.Traverse(Email.Create)` as a `Result, EmailFault>`, a clear and an + absence passing through; a `Map` selector that answers null is refused, since a clear is the Null + state and never a value. Writing an absent patch is refused, and `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out. Under a source-generated context a primitive, a string, a `Guid`, a date, a `Uri` and a value object need no listing; a class or an enum does. Works under Native AOT: the converter is registered by the closed type's static @@ -122,6 +129,16 @@ this release adds, so nothing that built against 1.5.1 meets them. CMTK0009 names each for `SingleOrDefault`, `ElementAtOrDefault` and `SingleOrDefaultAsync`. `Transpose()` between `Option>` and `Result, TError>`, and `Option.Traverse(selector)` for an optional input validated when present. +- **`CodoMetis.TypeKit`**: `Choose(selector)` on a sequence, the values the selector answers `Some` + for, in order and lazily, a filter and a map in one; and `Choose()` on a sequence of options, the + values they hold. The arguments are checked at the call, as LINQ's are. +- **`CodoMetis.TypeKit`**: `TraverseAsync` on a sequence, an asynchronous operation or command that + may fail applied to one element at a time, in order, nothing started after the first error. With a + cancellation token, the token is checked before each operation and handed to it, and a cancellation + propagates rather than becoming an error. Operations that may all run at once remain `Task.WhenAll` + over their tasks and `Sequence()` over what it answers. +- **`CodoMetis.TypeKit.Testing`**: `ShouldBeValue()`, which hands the value back, `ShouldBeNull()` and + `ShouldBeAbsent()` on a `Patch`, failing with the state the field was in and never the value. - **Documentation**: topic pages under `docs/`, linked from the root README's "Documentation" table: value objects, closed sets, `Option`, `Result`, written text, type constraints, merge patches, JSON, EF Core, ASP.NET Core, Native AOT, logging and secrets, testing, and migrating from 1.5, diff --git a/README.md b/README.md index 0bec7ec..93fd003 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # CodoMetis.TypeKit -Strong types for .NET 10: `Option` and `Result` types that cannot be misused, and value objects -that are written as one line and generated at compile time. +Strong types for .NET 10: `Option` and `Result` types that cannot be misused, and value objects and +closed sets that are written as one line and generated at compile time. [![.NET](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/actions/workflows/dotnet.yml/badge.svg)](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/actions/workflows/dotnet.yml) [![NuGet](https://img.shields.io/nuget/v/CodoMetis.TypeKit.svg)](https://www.nuget.org/packages/CodoMetis.TypeKit) @@ -24,6 +24,25 @@ factories, value equality, a JSON converter, `IParsable`, `IFormattable`, `IComp A value object can wrap a primitive, a `Guid`, a date, an enum, a `Uri`, a NodaTime type or a type of your own, and it can be declared inside another type. +A closed set, the smart enum, is declared the same way, one line per member: + +```csharp +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} +``` + +The generators add `Match` with one required delegate per member, so a member added later fails +every call until it is handled, `All`, parsing by name, JSON as the name, an OpenAPI string enum +and a text column in EF Core, with nothing registered per set. Beside the types that are generated: +`Patch`, the field of a JSON Merge Patch, absent, null or a value; `Result.Catch`, the one place +an exception becomes data; `AsText()` and `AsIdentifier()`, for text a person wrote and a value +another system issued; and `[Sealed]`, `[Concrete]` and `[RequireParameterlessConstructor]`, the +constraints C# has no `where` for, checked by the analyzers. + ## Packages | Package | Role | Metalama | @@ -34,7 +53,7 @@ your own, and it can be declared inside another type. | [CodoMetis.TypeKit.EntityFrameworkCore](src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) | Value objects as columns with nothing registered per type, and `.Value` in LINQ | no | | [CodoMetis.TypeKit.AspNetCore](src/CodoMetis.TypeKit.AspNetCore/README.md) | Value objects in the OpenAPI document, with the schema of the type they wrap; a `Result` as the response, a fault as problem details | no | | [CodoMetis.TypeKit.Serilog](src/CodoMetis.TypeKit.Serilog/README.md) | Value objects logged as what they print, also where Serilog destructures them, so a value masked by its `ToString()` stays masked | no | -| [CodoMetis.TypeKit.Testing](src/CodoMetis.TypeKit.Testing/README.md) | Test assertions for `Option` and `Result` that hand back the value or the error, and fail saying what the outcome was. No test framework dependency | no | +| [CodoMetis.TypeKit.Testing](src/CodoMetis.TypeKit.Testing/README.md) | Test assertions for `Option`, `Result` and `Patch` that hand back the value or the error, and fail saying what the outcome was. No test framework dependency | no | The base package is the cheapest one: someone who only wants `Option` and `Result` never receives a code generator by accident. Taking Metalama on is a named choice, made once, in the domain project, @@ -113,7 +132,7 @@ compiled and its stated outputs are tested, as the READMEs' are. | [ASP.NET Core](docs/aspnet-core.md) | Binding in minimal APIs and MVC, what a refused value answers, a `Result` as a response, fault responses a client can act on, the OpenAPI document, problem details | | [Native AOT](docs/native-aot.md) | What is measured, the project settings, JSON contexts, OpenAPI, EF Core's compiled model, configuration binding, discovery without reflection | | [Logging and secrets](docs/logging-and-secrets.md) | A value that must not be printed, what still carries it, Serilog, Microsoft.Extensions.Logging, refusals that never quote the input | -| [Testing](docs/testing.md) | Assertions for `Option` and `Result`, table-driven tests of `Create`, EF Core on SQLite, endpoints on the test server | +| [Testing](docs/testing.md) | Assertions for `Option`, `Result` and `Patch`, table-driven tests of `Create`, EF Core on SQLite, endpoints on the test server | | [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) | What builds or runs differently: CMTK0003 as an error, a query's `FirstOrNone` in the database, names that 2.0 adds, fault responses, Native AOT | ## What it holds to @@ -158,8 +177,9 @@ compiled and its stated outputs are tested, as the READMEs' are. ## How it compares Good libraries exist for both halves of TypeKit, and some needs are better met by one of them. What -follows was read from each library's source at the release named, on 28 September 2026, including the -next major where one is in preview. If something here is wrong or has changed, please open an issue. +follows was read from each library's source at the release named, on 28 September 2026 for value +objects, `Option` and `Result`, including the next major where one is in preview, and on 13 October +2026 for closed sets. If something here is wrong or has changed, please open an issue. ### Choose TypeKit when @@ -186,12 +206,13 @@ next major where one is in preview. If something here is wrong or has changed, p | A functional-programming framework: `Either`, `Fin`, effects, immutable collections | LanguageExt (4.4.9; 5.0 in beta) | TypeKit's `Option` and `Result` are deliberately small, with no effect or validation types | | Unions of arbitrary types | OneOf | `OneOf` is general-purpose; TypeKit has only `Option` and `Result` | | A `Result` on the wire | CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its `Result`. TypeKit refuses to serialize `Option` and `Result`, by design | +| A smart enum whose JSON or column is a number, or whose wire name differs from its C# identifier | Ardalis.SmartEnum, Intellenum or Thinktecture.Runtime.Extensions | A TypeKit closed set is its member's name everywhere, and the name is the field's | | Only your own closed set of outcomes, on .NET 11 | C#'s union types | They arrive with .NET 11 | -| Years of production use behind the library | Any of the above | TypeKit 1.0.0 is new | +| Years of production use behind the library | Any of the above | TypeKit is new: 1.0.0 shipped in September 2026 | ### Value objects in detail -| | TypeKit 1.0.0 | Vogen 8.0.7 (9.0.0-beta.2 the same) | Thinktecture.Runtime.Extensions 10.5.0 | StronglyTypedId 1.0.0-beta08 | +| | TypeKit 2.0.0 | Vogen 8.0.7 (9.0.0-beta.2 the same) | Thinktecture.Runtime.Extensions 10.5.0 | StronglyTypedId 1.0.0-beta08 | |---|---|---|---|---| | Declared by | Implementing `IValue` or `IValidatedValue<…>` on a `partial record` | `[ValueObject]` on a partial type | `[ValueObject]`, `[ComplexValueObject]` or `[SmartEnum]` on a partial type | `[StronglyTypedId]` on a partial struct | | Validation | `Create` returns a `Result`, and every generated way in applies it | `Validate` returns a `Validation`; `From` throws | A `ValidateFactoryArguments` partial method | None | @@ -204,7 +225,7 @@ next major where one is in preview. If something here is wrong or has changed, p ### Option and Result in detail -| | TypeKit 1.0.0 | LanguageExt 4.4.9 (5.0.0-beta-77) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | +| | TypeKit 2.0.0 | LanguageExt 4.4.9 (5.0.0-beta-77) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | |---|---|---|---|---| | Reaching the value | `Match`, `TryGetValue` and the combinators; no `.Value` | `Match`, `IfNone` and the like; no public `.Value` on `Option` | `.Value`, which throws on a failure | `.Value`, which returns `default` on an error | | A `default` instance | `Option`: `None`. `Result`: throws when used, and is an error in source (CMTK0001) | `Option`: `None` | `Maybe`: none. `Result`: a success holding `default(T)` | A success holding `default(T)` | @@ -212,6 +233,23 @@ next major where one is in preview. If something here is wrong or has changed, p | System.Text.Json | Refused in both directions, rather than written as `{}` | No converter of its own | Opt-in converters | No converter for the type | | Targets | `net10.0` | `netstandard2.0` (5.0 beta: `net10.0`) | `netstandard2.0`, `net6.0`, `net8.0` | `netstandard2.0`, `net8.0`, `net10.0` | +### Closed sets in detail + +| | TypeKit 2.0.0 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.5.1 | +|---|---|---|---|---| +| Declared by | Implementing `IClosedSet` on a `sealed partial class`, one `public static readonly` field per member | Deriving from `SmartEnum`, each member `new("Name", value)` | `[Intellenum]` on a partial class; members as fields, `[Member]` attributes or a static constructor | `[SmartEnum]` on a partial class, each member `new("KEY")` | +| Data on a member | Properties, set through the private constructor | The subclass's own fields | One underlying value; richer data through a custom underlying type | Declared properties, which become constructor parameters | +| Deciding by member | `Match`, one required delegate per member, every argument named (CMTK0012) | `When(…).Then(…)`; nothing requires every member | A `switch` over `.Value` with generated constants; nothing requires every member | Generated `Switch` and `Map`, every member a required argument | +| Parsing by name | `Parse` and `TryParse`, ordinal; `ignoreCase: true` on request, and two members differing only in case refused at build (CMTK1021) | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, a case clash thrown at the first such lookup | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, the first declared answers | `Get` and `TryGet`, ignoring case by default; a case clash thrown at the first lookup | +| JSON | System.Text.Json, the name, nothing registered | A separate package per serializer, a converter attribute per type; the name or the value | Generated into your assembly; the value, as an `enum` writes | A separate package per serializer; the key | +| EF Core | `UseTypeKit()`, a text column of the name, nothing per set | A separate package, a convention or per model; the value | An opt-in generated converter, registered per property; the value | A separate package per EF version, `UseThinktectureValueConverters()`; the key | +| OpenAPI | `AddTypeKit()`, a string enum of the names | None built in | None built in | A Swashbuckle package | +| Analyzers | CMTK1013–1021 refuse a declaration that cannot be generated, CMTK0012 an argument by position | None | INTELLENUM001–030 | TTRESG rules, with refactorings | +| A wire name apart from the identifier | No: the name is the field's | Yes, the constructor's name | Yes, `new("STD", 1)` | Yes, the key | +| Targets | `net10.0` | `netstandard2.0`, `net6.0` to `net8.0` | `netstandard2.0` | `net8.0`, `net9.0` | +| Generated by | Metalama, in the declaring project | Nothing: a run-time base class | A Roslyn source generator | Roslyn source generators | +| License | MIT | MIT | Apache-2.0 | BSD-3-Clause | + ## Built with Metalama The value-object generation exists in this form because of [Metalama](https://postsharp.net/metalama). @@ -231,7 +269,9 @@ your projects. Thanks to the Metalama team. ## Status -1.0.0 is the first release. The packages share one version and follow Semantic Versioning: +2.0.0 is the current version; 1.0.0, of 28 September 2026, was the first release, and +[Migrating from 1.5 to 2.0](docs/migrating-to-2.md) lists what builds or runs differently since 1.5. +The packages share one version and follow Semantic Versioning: the public API, the generated members of a value object, and the analyzer ids and severities are the contract. A new analyzer rule arrives as a warning or a suggestion in a minor version, and becomes an error only in a major one. diff --git a/design/plan.md b/design/plan.md index 23a3326..8b94f88 100644 --- a/design/plan.md +++ b/design/plan.md @@ -674,6 +674,33 @@ Each phase ends green, and its guards have been proven by seeding the defect test's `aot` consumer now measures: the base package's `FirstOrNone` on a query and the satellite's `FirstOrNoneAsync` throw "Query wasn't precompiled", and the operators written at the call site run. + 9b. **A second review's picks** (2026-10-13): two outside reviews of the release were checked + against the code (one named `Choose`, result aggregation and a dictionary lookup as missing, + where `Zip`, `Sequence`, `Traverse` and `GetValueOrNone` exist, and read CMTK0006 as a `.Value` + guard), and the user took what held, each in this release: + - **`Choose`** ✅: on a sequence with a selector answering an option, and on a sequence of + options; lazy, the arguments checked at the call (F#'s `choose`, CSharpFunctionalExtensions' + `Choose`). + - **`Patch.Map` and `Traverse`** ✅: a patch over what the request sent, validated into a + patch over the domain's type with `Create`'s own fault, a clear and an absence passing + through, `Option.Traverse`'s pattern; a `Map` selector answering null is refused, since + a clear is the Null state and never a value. + - **`TraverseAsync`** ✅, sequential: one operation awaited at a time, in order, nothing + started after the first error; the token overloads check it before each operation and hand + it on, and a cancellation propagates, as `Catch` lets it. Concurrency stays explicit, + `Task.WhenAll` and `Sequence()`. + - **Patch assertions** ✅ in `.Testing`: `ShouldBeValue()` hands the value back, + `ShouldBeNull()` and `ShouldBeAbsent()` name the state, never the value. + - **A closed set parsed ignoring case** ✅ (decision 51): `Parse(name, ignoreCase)` and + `TryParse(name, ignoreCase, out member)`, opted into at the call; two members whose names + differ only in case are CMTK1021, which subsumes the `Match`-parameter collision. + - **The README** ✅ refreshed for 2.0.0: the status, the comparison tables' version, the pitch + naming closed sets and the release's other additions, and a closed-set comparison against + Ardalis.SmartEnum 8.2.0, Intellenum 2.0.1 and Thinktecture.Runtime.Extensions 10.5.1, read + from source and nuspecs on 2026-10-13. + - Not taken: error accumulation and a `Combine` (§9 non-goals). A stable external name for a + closed-set member, apart from its C# identifier, touches parsing, JSON, EF, OpenAPI and fault + identifiers at once and needs a spike first; it is 2.x. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the @@ -1608,6 +1635,16 @@ Still open: renumbered by members added before them, nullable-context bytes) and no renamed parameter. The analyzer's severity and the query overloads' binding are behaviour no compatibility check reads; the changelog states each. +51. **A closed set parses ignoring case only when asked, and refuses members that differ only in + case** (2026-10-13, phase 13). The ordinal parse stays the default and the only one JSON, the + column, binding and `IParsable` use, since what is stored or sent is the name as it is. A header + or a query string a client typed is read with `ignoreCase: true` at the call, as + Ardalis.SmartEnum's `FromName(name, ignoreCase)` is, and not through a per-set setting, which + would change what the wire accepts without a word at the call site. With the overload, two + members whose names differ only in case are one name to the parse, so CMTK1021 refuses them at + the declaration, where it already refused the first-letter case (`Open`, `open`) that gave + `Match` one parameter twice. The peers meet such pairs at run time: Ardalis throws on the first + ignore-case lookup, Thinktecture on the first `Get`, and Intellenum answers the first declared. ## 9. Option and Result diff --git a/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs b/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs index 7d9ed71..7b5b15a 100644 --- a/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Root/IntroSamples.cs @@ -1,4 +1,5 @@ using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.ValueObjects; namespace ReadmeSamples.Root.Intro; @@ -14,3 +15,12 @@ public static Result Create(string value) => value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt); } // end sample + +// sample: README/closed-set +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} +// end sample From 78e6bb0171a8a3bc8a6ad3b7db6523fbf546c65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:22:43 +0200 Subject: [PATCH 037/147] fix: a closed set in two files lists every member, a nested set is qualified by its containers, and a non-public member-shaped field is refused `All` was a field initializer of the part the generators introduced it into, which ran before the fields of a part in another file: a set declared in two partial files listed the second file's members as null, and the names the satellites read threw (ran: "First,NULL"). It is filled by the names initializer, after every part's fields. `QualifiedName` was the simple name's, so two nested sets named Status were both "Status.Open"; it is the nesting chain. An internal static readonly field of the set's own type compiled as no member, whose name threw and which TryParse never found; it is CMTK1017, as a writable or property-shaped one is. The nested JSON and type converters are sealed, which is free now and a break later. Seeded: with the field initializer back, the split set lists a null; with the public-only filter back, the internal member goes unreported. Co-Authored-By: Claude Fable 5.1 --- .../ClosedSetAspect.Templates.cs | 11 +++- .../ClosedSetAspect.cs | 19 ++++--- .../ClosedSetTypes.cs | 8 +++ .../Probes.cs | 24 +++++++++ .../SplitProbes.cs | 7 +++ .../BuildOutcomeTests.cs | 4 ++ .../ClosedSetTests.cs | 24 +++++++++ .../GeneratedSurface.verified.txt | 54 +++++++++++++++++++ 8 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Generators.Probes/SplitProbes.cs diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs index b904e88..9abdf5d 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -34,16 +34,21 @@ public string NameTemplate } } + /// The set's name, with its containing types for a nested set, and the member's: two Status sets stay apart. [Template] public string QualifiedNameTemplate { - get => meta.Target.Type.Name + "." + meta.This.Name; + get => ClosedSetTypes.NestingChain(meta.Target.Type) + "." + meta.This.Name; } [Template] public string ToStringTemplate() => meta.This.Name; - /// After the static field initializers, before any static constructor body: each member learns its name. + /// + /// After the static field initializers of every part, before any static constructor body: each + /// member learns its name, and All is filled, since a field initializer ran with its own + /// part only and listed a member declared in another file as null. + /// [Template] public static void NamesInitializerTemplate() { @@ -52,6 +57,8 @@ public static void NamesInitializerTemplate() { member.Value!._name = member.Name; } + + meta.ThisType._all = ExpressionFactory.Parse(ClosedSetTypes.AllExpression(args.Set, args.Members)).Value; } [Template] diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index a5206ee..70320bb 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -54,9 +54,12 @@ public override void BuildAspect(IAspectBuilder builder) foreach (var member in members.Where(member => !ClosedSetTypes.IsCreatedWhereDeclared(member, target))) problems.Add(ClosedSetMemberNotCreated.WithArguments((target, member.Name))); - foreach (var field in target.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(target) && f.Writeability != Writeability.ConstructorOnly)) - problems.Add(ClosedSetMemberNotAField.WithArguments((target, field.Name, "a static field that is not readonly"))); - foreach (var property in target.Properties.Where(p => p.IsStatic && p.Accessibility == Accessibility.Public && p.Type.Equals(target))) + // Any static field or property of the set's own type that is not a public readonly field: a + // writable one may be replaced, and a non-public one is an instance that is no member, whose + // name throws and which TryParse never finds, while the declaration reads as a member. + foreach (var field in target.Fields.Where(f => f.IsStatic && f.Type.Equals(target) && (f.Accessibility != Accessibility.Public || f.Writeability != Writeability.ConstructorOnly))) + problems.Add(ClosedSetMemberNotAField.WithArguments((target, field.Name, field.Accessibility != Accessibility.Public ? "a static field that is not public" : "a static field that is not readonly"))); + foreach (var property in target.Properties.Where(p => p.IsStatic && p.Type.Equals(target))) problems.Add(ClosedSetMemberNotAField.WithArguments((target, property.Name, "a static property"))); // A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and @@ -113,15 +116,17 @@ public override void BuildAspect(IAspectBuilder builder) builder.AddInitializer(nameof(NamesInitializerTemplate), InitializerKind.BeforeTypeConstructor); - // The array wrapped, never the array itself: cast to IList or T[], it was writable, and a - // member replaced there was listed while Parse, which reads the fields, answered the original. + // Assigned by the initializer below, with the names: as a field initializer it ran with the + // part it was introduced into, before the fields of a part in another file, which were null in + // it. The array wrapped, never the array itself: cast to IList or T[], it was writable, and + // a member replaced there was listed while Parse, which reads the fields, answered the original. var listType = TypeFactory.GetNamedType(typeof(IReadOnlyList<>)).WithTypeArguments(target); builder.IntroduceField(nameof(_all), IntroductionScope.Static, OverrideStrategy.Fail, f => { f.Accessibility = Accessibility.Private; f.Type = listType; f.Writeability = Writeability.ConstructorOnly; - f.InitializerExpression = ExpressionFactory.Parse($"global::System.Array.AsReadOnly(new {ValueObjectTypes.SourceName(target)}[] {{ {string.Join(", ", members.Select(m => $"{ValueObjectTypes.SourceName(target)}.{m.Name}"))} }})"); + f.InitializerExpression = ExpressionFactory.Parse("null!"); f.AddAttribute(CodeAnnotations.CompilerGenerated); }); builder.IntroduceProperty(nameof(AllTemplate), buildProperty: p => @@ -196,6 +201,7 @@ public override void BuildAspect(IAspectBuilder builder) t.Accessibility = Accessibility.Public; t.BaseType = TypeFactory.GetNamedType(typeof(JsonConverter<>)).WithTypeArguments(target); t.IsStatic = false; + t.IsSealed = true; t.AddAttribute(CodeAnnotations.CompilerGenerated); }); converter.IntroduceMethod(nameof(JsonReadTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = "Read"; m.ReturnType = target; m.AddAttribute(CodeAnnotations.CompilerGenerated); }); @@ -210,6 +216,7 @@ public override void BuildAspect(IAspectBuilder builder) t.Accessibility = Accessibility.Public; t.BaseType = TypeFactory.GetNamedType(typeof(TypeConverter)); t.IsStatic = false; + t.IsSealed = true; t.AddAttribute(CodeAnnotations.CompilerGenerated); }); typeConverter.IntroduceMethod(nameof(CanConvertFromTemplate), IntroductionScope.Instance, OverrideStrategy.Override, m => { m.Name = nameof(TypeConverter.CanConvertFrom); m.AddAttribute(CodeAnnotations.CompilerGenerated); }); diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index 10b7907..79b357e 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -22,6 +22,14 @@ public static bool IsClosedSet(INamedType type) => public static IReadOnlyList Members(INamedType type) => [.. type.Fields.Where(f => f.IsStatic && f.Accessibility == Accessibility.Public && f.Type.Equals(type) && f.Writeability == Writeability.ConstructorOnly)]; + /// The set's name with its containing types, dotted: Order.Status for a set nested in Order. + public static string NestingChain(INamedType set) => + set.DeclaringType is { } declaring ? NestingChain(declaring) + "." + set.Name : set.Name; + + /// The expression that makes All: the members in declaration order, wrapped read-only. + public static string AllExpression(INamedType set, IReadOnlyList members) => + $"global::System.Array.AsReadOnly(new {ValueObjectTypes.SourceName(set)}[] {{ {string.Join(", ", members.Select(member => $"{ValueObjectTypes.SourceName(set)}.{member.Name}"))} }})"; + /// The names of what the generators introduce into the set itself; nothing else in it may have one (CMTK1011, CMTK1021). public static IReadOnlyList GeneratedNames(INamedType set) => ["Name", "QualifiedName", "All", "Match", "Parse", "TryParse", "ToString", "_name", "_all", $"{set.Name}JsonConverter", $"{set.Name}TypeConverter"]; diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 2559c26..1b822ef 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -195,6 +195,30 @@ public sealed partial class ProbeStatus : IClosedSet public static readonly ProbeStatus Cancelled = new(); } +/// A closed set declared in two parts, this one and SplitProbes.cs: All lists the member of either. +public sealed partial class ProbeSplit : IClosedSet +{ + public static readonly ProbeSplit First = new(); +} + +/// Two nested sets of one simple name, told apart by their containers. +public static class ProbeOrder +{ + public sealed partial class Status : IClosedSet + { + public static readonly Status Open = new(); + } +} + +/// See . +public static class ProbeShipment +{ + public sealed partial class Status : IClosedSet + { + public static readonly Status Sent = new(); + } +} + /// A closed set with constant data per member, through a private constructor. public sealed partial class ProbeTier : IClosedSet { diff --git a/test/CodoMetis.TypeKit.Generators.Probes/SplitProbes.cs b/test/CodoMetis.TypeKit.Generators.Probes/SplitProbes.cs new file mode 100644 index 0000000..16982d2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Probes/SplitProbes.cs @@ -0,0 +1,7 @@ +namespace CodoMetis.TypeKit.Generators.Probes; + +/// The second part of , in its own file: its member's field initializer runs apart from the first part's. +public sealed partial class ProbeSplit +{ + public static readonly ProbeSplit Second = new(); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index c0f9aa8..205d9b9 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -110,6 +110,7 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1016", "SetWithPublicConstructor")] [InlineData("CMTK1017", "SetWithProperty")] [InlineData("CMTK1017", "SetWithMutableField")] + [InlineData("CMTK1017", "SetWithInternalMember")] [InlineData("CMTK1018", "SetGeneric")] [InlineData("CMTK1016", "SetWithInternalConstructor")] [InlineData("CMTK1019", "SetWithAlias.Legacy")] @@ -499,6 +500,9 @@ public sealed partial class SetWithoutMembers : IClosedSet { public sealed partial class SetWithCasedMembers : IClosedSet { public static readonly SetWithCasedMembers Open = new(); public static readonly SetWithCasedMembers open = new(); } + // Read as a member, it was none: its name threw and TryParse never found it. + public sealed partial class SetWithInternalMember : IClosedSet { public static readonly SetWithInternalMember A = new(); internal static readonly SetWithInternalMember Draft = new(); } + public sealed partial class SetWithCaseVariants : IClosedSet { public static readonly SetWithCaseVariants Express = new(); public static readonly SetWithCaseVariants EXPRESS = new(); } public sealed partial class SetWithIgnoreCaseTryParse : IClosedSet { public static readonly SetWithIgnoreCaseTryParse A = new(); public static bool TryParse(string? name, bool ignoreCase, out SetWithIgnoreCaseTryParse? member) { member = A; return true; } } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 9b86610..6e4b0e8 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -74,6 +74,30 @@ public void Match_takes_one_delegate_per_member_and_picks_the_branch() ProbeTier.Basic.Match(basic: () => 1, premium: () => 2).ShouldBe(1); } + /// + /// All was a field initializer of the part the generators introduced it into, which ran before + /// the fields of a part in another file: All listed them as null, and the names the satellites + /// read threw. It is filled with the names, after every part's fields. + /// + [Fact] + public void A_set_declared_in_two_files_lists_the_members_of_both() + { + ProbeSplit.All.ShouldBe([ProbeSplit.First, ProbeSplit.Second]); + ProbeSplit.All.ShouldAllBe(member => member != null); + ProbeSplit.TryParse("Second", out var second).ShouldBeTrue(); + second.Name.ShouldBe("Second"); + new GeneratedClosedSetAttribute().Names.ShouldBe(["First", "Second"]); + } + + /// Two nested sets of one simple name are told apart by their containers, in a log and in a fault's identifier. + [Fact] + public void A_nested_set_is_qualified_by_its_containing_types() + { + ProbeOrder.Status.Open.QualifiedName.ShouldBe("ProbeOrder.Status.Open"); + ProbeShipment.Status.Sent.QualifiedName.ShouldBe("ProbeShipment.Status.Sent"); + ProbeStatus.Open.QualifiedName.ShouldBe("ProbeStatus.Open"); + } + [Fact] public void TryParse_and_Parse_find_a_member_by_name_and_refuse_without_quoting() { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 607feaa..8028ebb 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -844,6 +844,24 @@ ProbeName static op_LessThanOrEqual(ProbeName, ProbeName) : Boolean extension static ProbeNameExtensions.GetValue(ProbeName) : String extension static ProbeNameExtensions.ValueOrNull(ProbeName?) : String +ProbeOrder.Status + Match(Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested StatusJsonConverter : JsonConverter + nested StatusTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeOrder.Status + static Parse(String, Boolean) : ProbeOrder.Status + static TryParse(String, Boolean, out ProbeOrder.Status) : Boolean + static TryParse(String, out ProbeOrder.Status) : Boolean ProbeOrderFault Description : String Kind : FaultKind @@ -1115,6 +1133,24 @@ ProbeSecret static op_LessThanOrEqual(ProbeSecret, ProbeSecret) : Boolean extension static ProbeSecretExtensions.GetValue(ProbeSecret) : String extension static ProbeSecretExtensions.ValueOrNull(ProbeSecret) : String +ProbeShipment.Status + Match(Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested StatusJsonConverter : JsonConverter + nested StatusTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeShipment.Status + static Parse(String, Boolean) : ProbeShipment.Status + static TryParse(String, Boolean, out ProbeShipment.Status) : Boolean + static TryParse(String, out ProbeShipment.Status) : Boolean ProbeSku Equals(Object) : Boolean Equals(ProbeSku) : Boolean @@ -1173,6 +1209,24 @@ ProbeSpan static op_Inequality(ProbeSpan, ProbeSpan) : Boolean extension static ProbeSpanExtensions.GetValue(ProbeSpan) : ProbeSpanText extension static ProbeSpanExtensions.ValueOrNull(ProbeSpan?) : ProbeSpanText +ProbeSplit + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested ProbeSplitJsonConverter : JsonConverter + nested ProbeSplitTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : ProbeSplit + static Parse(String, Boolean) : ProbeSplit + static TryParse(String, Boolean, out ProbeSplit) : Boolean + static TryParse(String, out ProbeSplit) : Boolean ProbeStatus Match(Func, Func, Func) : TResult Name : String From 1e01a7eae8f69a3d1a49de20f88fca014f8c345c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:22:43 +0200 Subject: [PATCH 038/147] fix: a closed set is a string enum as a parameter and a container's element, and nested sets get their own components The closed-set transformer read the JSON type only, so a set was its enum as a body property and nothing else: a route or query parameter was ASP.NET's placeholder string, a query array had no items, a List property no items and a dictionary no additionalProperties. It now takes the value object transformer's three positions: the set itself, a parameter bound through the generated TryParse (minimal APIs name it in the parameter's type, MVC in its model metadata), and a container's element, put back only where ASP.NET left it out. Two nested sets of one simple name shared one component, named after whichever was described first; the nesting-chain id that names a nested value object's component names a set's too. Seeded: without the two positions and the id, the five new cases fail. Co-Authored-By: Claude Fable 5.1 --- .../ClosedSetSchemaTransformer.cs | 43 +++++++++++-- .../ValueObjectSchemaReferenceIds.cs | 14 +++-- .../ClosedSetSchemaTests.cs | 63 +++++++++++++++++++ 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs index e6ebfbc..c9250b7 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Reflection; +using System.Text.Json.Serialization.Metadata; using CodoMetis.TypeKit.CompilerServices; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; @@ -11,6 +13,13 @@ namespace CodoMetis.TypeKit.AspNetCore; /// described, read from the attribute the generators put on the set, never by reflection over /// its members. /// +/// +/// The three positions of : a set in a JSON position +/// arrives as itself; a route or query parameter bound through the generated TryParse +/// arrives as ASP.NET's placeholder , and is the enum as an enum +/// parameter is; and a container of sets arrives without its element, which ASP.NET drops for a +/// converter-backed element, so the set's own schema is put back where it is missing. +/// internal sealed class ClosedSetSchemaTransformer : IOpenApiSchemaTransformer { private static readonly ConcurrentDictionary Descriptions = new(); @@ -19,15 +28,41 @@ internal sealed class ClosedSetSchemaTransformer : IOpenApiSchemaTransformer Descriptions.GetOrAdd(type, static candidate => candidate.GetCustomAttribute(inherit: false) is { } set && set.SetType == candidate ? set : null); - public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { - if (Describe(context.JsonTypeInfo.Type) is not { } set) return Task.CompletedTask; + var info = context.JsonTypeInfo; + if (Describe(info.Type) is { } set) + { + Fill(schema, set); + return; + } + + if (context is { JsonPropertyInfo: null, ParameterDescription: { } parameter } && ParameterSet(parameter) is { } parameterSet) + { + Fill(schema, parameterSet); + return; + } + + if (info.ElementType is not { } element || Describe(element) is null) return; + + // Only what ASP.NET left out: a schema it did build is its own description of the elements. + if (info.Kind == JsonTypeInfoKind.Enumerable && schema.Items is null) + schema.Items = await context.GetOrCreateSchemaAsync(element, parameterDescription: null, cancellationToken); + else if (info.Kind == JsonTypeInfoKind.Dictionary && schema.AdditionalProperties is null) + schema.AdditionalProperties = await context.GetOrCreateSchemaAsync(element, parameterDescription: null, cancellationToken); + } + + private static void Fill(OpenApiSchema schema, GeneratedClosedSetAttribute set) + { schema.Properties?.Clear(); schema.Required?.Clear(); schema.Type = JsonSchemaType.String; schema.Enum = [.. set.Names.Select(name => (System.Text.Json.Nodes.JsonNode)name)]; - - return Task.CompletedTask; } + + /// The set a parameter binds to: minimal APIs name it in the parameter's type, MVC in its model metadata. + private static GeneratedClosedSetAttribute? ParameterSet(ApiParameterDescription parameter) => + Describe(parameter.Type) + ?? (parameter.ModelMetadata?.ModelType is { } model ? Describe(model) : null); } diff --git a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs index b96d536..9ae8e66 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaReferenceIds.cs @@ -3,14 +3,16 @@ namespace CodoMetis.TypeKit.AspNetCore; /// -/// Names a nested value object's component after its whole nesting chain: Shop.Id is -/// ShopId, as its generated companion class is ShopIdExtensions. +/// Names a nested value object's or closed set's component after its whole nesting chain: +/// Shop.Id is ShopId, as its generated companion class is ShopIdExtensions, and +/// Order.Status is OrderStatus. /// /// /// ASP.NET names a component after the type's simple name, so Shop.Id and Stock.Id /// shared one component Id, and whichever was described first described both: a stock id /// wrapping an was documented as a uuid (measured with Microsoft.AspNetCore.OpenApi -/// 10.0.12). Only ASP.NET's default name is replaced: a name the host's own +/// 10.0.12), and Order.Status and Shipment.Status shared one enum of the first's +/// names. Only ASP.NET's default name is replaced: a name the host's own /// chose, or for a value /// object it inlines, is kept. /// @@ -25,10 +27,10 @@ public static void Apply(OpenApiOptions options) var id = inner(type); return id is not null - && (Nullable.GetUnderlyingType(type.Type) ?? type.Type) is { IsNested: true } valueObject - && ValueObjectTypes.IsValueObject(valueObject) + && (Nullable.GetUnderlyingType(type.Type) ?? type.Type) is { IsNested: true } nested + && (ValueObjectTypes.IsValueObject(nested) || ClosedSetSchemaTransformer.Describe(nested) is not null) && id == OpenApiOptions.CreateDefaultSchemaReferenceId(type) - ? NestingChain(valueObject) + ? NestingChain(nested) : id; }; } diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs index aa7b51e..ec6b514 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs @@ -26,4 +26,67 @@ public async Task A_closed_set_is_a_string_enum_of_its_members(OpenApiSpecVersio status["enum"]!.AsArray().Select(node => node!.GetValue()).ShouldBe(["Open", "Shipped", "Cancelled"]); status["properties"].ShouldBeNull(); } + + public sealed record Board(List Statuses, ProbeStatus[] History, Dictionary ByName); + + public sealed record Shipments(ProbeOrder.Status Order, ProbeShipment.Status Shipment); + + private static IEnumerable Names(JsonNode schema) => + schema["enum"].ShouldNotBeNull($"no enum: {schema.ToJsonString()}").AsArray().Select(node => node!.GetValue()); + + /// + /// A set bound from the route or the query string arrives as ASP.NET's placeholder string, as a + /// value object does, and was published as a bare string: it is the enum an enum parameter + /// is, and a query array of sets has the enum as its items. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task A_closed_set_parameter_is_the_string_enum_an_enum_parameter_is(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, endpoints: app => + { + app.MapGet("/tickets/{status}", int (ProbeStatus status) => 0); + app.MapGet("/tickets", int (ProbeStatus status, ProbeStatus[] any) => 0); + }); + + foreach (var (path, name) in new[] { ("/tickets/{status}", "status"), ("/tickets", "status") }) + { + var parameter = document.Resolve(document.Parameter(path, "get", name)); + + parameter["type"]!.GetValue().ShouldBe("string", $"{path} {name}: {parameter.ToJsonString()}"); + Names(parameter).ShouldBe(["Open", "Shipped", "Cancelled"], $"{path} {name}"); + } + + var items = document.Parameter("/tickets", "get", "any")["items"].ShouldNotBeNull("the query array lost its items"); + Names(document.Resolve(items)).ShouldBe(["Open", "Shipped", "Cancelled"]); + } + + /// ASP.NET drops the element of a container of converter-backed types; the set's own schema is put back, as a value object's is. + [Theory] + [MemberData(nameof(Versions))] + public async Task A_container_of_closed_sets_has_their_enum_as_its_elements(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version, + endpoints: app => app.MapPost("/boards", int (Board board) => 0)); + + foreach (var (property, keyword) in new[] { ("statuses", "items"), ("history", "items"), ("byName", "additionalProperties") }) + { + var element = document.Property(nameof(Board), property)[keyword].ShouldNotBeNull($"'{property}' lost its {keyword}"); + + Names(document.Resolve(element)).ShouldBe(["Open", "Shipped", "Cancelled"], property); + } + } + + /// Two nested sets of one simple name shared one component, named after the first described, whose names then stood for both. + [Fact] + public async Task Two_nested_sets_of_one_simple_name_are_two_components() + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), + endpoints: app => app.MapPost("/shipments", int (Shipments shipments) => 0)); + + Names(document.Resolve(document.Property(nameof(Shipments), "order"))).ShouldBe(["Open"]); + Names(document.Resolve(document.Property(nameof(Shipments), "shipment"))).ShouldBe(["Sent"]); + document.Components.ContainsKey("ProbeOrderStatus").ShouldBeTrue(string.Join(", ", document.Components.Select(c => c.Key))); + document.Components.ContainsKey("ProbeShipmentStatus").ShouldBeTrue(); + } } From d487fb43b522f1e779e3c2873994997394c41028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:22:43 +0200 Subject: [PATCH 039/147] fix: an index from the end on a query, a fault compared with a bare reason, and CatchAsync over no task are refused `query.ElementAtOrNone(^1)` bound to the sequence overload and read every row, the case LastOrNone was made a compile error for (decision 47); it is one too, naming ElementAtOrNone(int) and AsEnumerable(). `fault == OrderFault.NotFound` on a Fault compiled through the implicit conversion and was false whenever the fault carried a detail, and so was Equals(reason); the mixed operators and that Equals are compile errors naming Reason. `CatchAsync` given a delegate that returned no task answered an error holding the NullReferenceException the await threw, where the changelog said it throws; the delegate runs inside the try, its task is checked outside it, and only then awaited, so a delegate that throws before returning a task is still that exception as the error. Seeded: without the three refusals, eight tests fail. Co-Authored-By: Claude Fable 5.1 --- src/CodoMetis.TypeKit/Fault.cs | 45 +++++++++++++ src/CodoMetis.TypeKit/OptionExtensions.cs | 7 ++ src/CodoMetis.TypeKit/ResultExtensions.cs | 64 ++++++++++++++++--- src/CodoMetis.TypeKit/ThrowHelper.cs | 9 +++ test/CodoMetis.TypeKit.Tests/FaultTests.cs | 52 +++++++++++++++ .../QueryableOptionTests.cs | 11 ++++ .../RefusalLinkTests.cs | 1 + .../ResultCatchTests.cs | 24 +++++++ 8 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Tests/FaultTests.cs diff --git a/src/CodoMetis.TypeKit/Fault.cs b/src/CodoMetis.TypeKit/Fault.cs index 06cbe52..8d02bee 100644 --- a/src/CodoMetis.TypeKit/Fault.cs +++ b/src/CodoMetis.TypeKit/Fault.cs @@ -46,6 +46,51 @@ public Fault(TFault reason, string? detail = null) /// A reason as a fault without a detail. /// Which fault. Never null. public static implicit operator Fault(TFault reason) => new(reason); + + // A bare reason converts to a fault without a detail, so `fault == OrderFault.NotFound` compiled + // and was false whenever the fault carried one. The mixed forms are refused where they are + // written; the comparison that is meant is fault.Reason == reason. + private const string MixedComparison = + "A Fault is never compared with a bare reason: the reason converts to a fault without a detail, so the comparison is false whenever the fault carries one. Compare fault.Reason with the reason."; + + /// Refused: compare with the reason. + /// The fault. + /// The reason. + /// Never: the operator does not compile. + [Obsolete(MixedComparison, error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static bool operator ==(Fault fault, TFault reason) => throw new NotSupportedException(); + + /// Refused: compare with the reason. + /// The fault. + /// The reason. + /// Never: the operator does not compile. + [Obsolete(MixedComparison, error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static bool operator !=(Fault fault, TFault reason) => throw new NotSupportedException(); + + /// Refused: compare the reason with . + /// The reason. + /// The fault. + /// Never: the operator does not compile. + [Obsolete(MixedComparison, error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static bool operator ==(TFault reason, Fault fault) => throw new NotSupportedException(); + + /// Refused: compare the reason with . + /// The reason. + /// The fault. + /// Never: the operator does not compile. + [Obsolete(MixedComparison, error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public static bool operator !=(TFault reason, Fault fault) => throw new NotSupportedException(); + + /// Refused: compare with the reason. + /// The reason. + /// Never: the method does not compile. + [Obsolete(MixedComparison, error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public bool Equals(TFault reason) => throw new NotSupportedException(); } /// Creates a , inferring its type. diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index ed5f1c8..d5b5e57 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -579,6 +579,13 @@ public Option ElementAtOrNone(int index) return index < 0 ? None() : FirstOf(source.Skip(index).Take(1)); } + /// Refused: a query skips to an index from the start, and one from the end would read every row to find it. + /// The index. + /// Never: the overload does not compile. + [Obsolete("A query has no end until it is ordered, and ElementAtOrNone(^n) would read every row to find it. Order the query the other way and use ElementAtOrNone(int), or call AsEnumerable() to read it all on purpose.", error: true)] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public Option ElementAtOrNone(Index index) => throw new NotSupportedException(); + /// Refused: a query has no last element until it is ordered, and this would read every row to find one. /// Never: the overload does not compile. [Obsolete("A query has no last element until it is ordered, and LastOrNone would read every row to find one. Order it the other way and use FirstOrNone, or call AsEnumerable() to read it all on purpose.", error: true)] diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 0c6b2fb..7250186 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -259,17 +259,29 @@ private static void RefuseTask(string method) /// The asynchronous call whose outcome is wanted. /// The type of the value. /// A success holding the value, or an error holding the exception thrown. - /// is null, or its task produced null. + /// is null, returned no task, or its task produced null. /// is itself a task; await it in the delegate. public static async Task> CatchAsync(Func> action) where T : notnull { ArgumentNullException.ThrowIfNull(action); RefuseTask(nameof(CatchAsync)); + Task task; + try + { + task = action(); + } + catch (Exception exception) when (!IsCancellation(exception)) + { + return Result.Error(exception); + } + + if (task is null) throw ThrowHelper.NullTask(nameof(action)); + T value; try { - value = await action().ConfigureAwait(false); + value = await task.ConfigureAwait(false); } catch (Exception exception) when (!IsCancellation(exception)) { @@ -285,14 +297,26 @@ public static async Task> CatchAsync(Func> actio ///
/// The asynchronous call whose outcome is wanted. /// A success, or an error holding the exception thrown. - /// is null. + /// is null, or returned no task. public static async Task> CatchAsync(Func action) { ArgumentNullException.ThrowIfNull(action); + Task task; + try + { + task = action(); + } + catch (Exception exception) when (!IsCancellation(exception)) + { + return Result.Error(exception); + } + + if (task is null) throw ThrowHelper.NullTask(nameof(action)); + try { - await action().ConfigureAwait(false); + await task.ConfigureAwait(false); } catch (Exception exception) when (!IsCancellation(exception)) { @@ -311,7 +335,7 @@ public static async Task> CatchAsync(Func action) /// The type of the value. /// The exception that is an outcome; any other propagates. /// A success holding the value, or an error holding the exception thrown. - /// is null, or its task produced null. + /// is null, returned no task, or its task produced null. /// is itself a task; await it in the delegate. public static async Task> CatchAsync(Func> action) where T : notnull @@ -320,10 +344,22 @@ public static async Task> CatchAsync(Func(nameof(CatchAsync)); + Task task; + try + { + task = action(); + } + catch (TException exception) when (!IsCancellation(exception)) + { + return Result.Error(exception); + } + + if (task is null) throw ThrowHelper.NullTask(nameof(action)); + T value; try { - value = await action().ConfigureAwait(false); + value = await task.ConfigureAwait(false); } catch (TException exception) when (!IsCancellation(exception)) { @@ -340,15 +376,27 @@ public static async Task> CatchAsync(FuncThe asynchronous call whose outcome is wanted. /// The exception that is an outcome; any other propagates. /// A success, or an error holding the exception thrown. - /// is null. + /// is null, or returned no task. public static async Task> CatchAsync(Func action) where TException : Exception { ArgumentNullException.ThrowIfNull(action); + Task task; + try + { + task = action(); + } + catch (TException exception) when (!IsCancellation(exception)) + { + return Result.Error(exception); + } + + if (task is null) throw ThrowHelper.NullTask(nameof(action)); + try { - await action().ConfigureAwait(false); + await task.ConfigureAwait(false); } catch (TException exception) when (!IsCancellation(exception)) { diff --git a/src/CodoMetis.TypeKit/ThrowHelper.cs b/src/CodoMetis.TypeKit/ThrowHelper.cs index a105e0e..a12db43 100644 --- a/src/CodoMetis.TypeKit/ThrowHelper.cs +++ b/src/CodoMetis.TypeKit/ThrowHelper.cs @@ -33,6 +33,15 @@ internal static class ThrowHelper /// The exception, to throw. public static ArgumentNullException NullContent(string paramName) => new(paramName, NullContentMessage); + private const string NullTaskMessage = + "The delegate given to CatchAsync returned no task: a null cannot be awaited, and it is not an exception the call threw. " + + "Return the task, or make the lambda async." + Refusals.NullContent; + + /// What CatchAsync throws for a delegate that returned a null task, before anything is awaited. + /// The delegate's parameter. + /// The exception, to throw. + public static ArgumentNullException NullTask(string paramName) => new(paramName, NullTaskMessage); + /// What Patch<T>.Map throws for a selector that returned null. /// The selector's parameter. /// The exception, to throw. diff --git a/test/CodoMetis.TypeKit.Tests/FaultTests.cs b/test/CodoMetis.TypeKit.Tests/FaultTests.cs new file mode 100644 index 0000000..803e069 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/FaultTests.cs @@ -0,0 +1,52 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// : a reason and a sentence about one occurrence. A bare reason converts +/// to a fault without a detail, so comparing a fault with a reason compiled and was false whenever the +/// fault carried a detail; the mixed comparisons are compile errors that name Reason. +/// +public sealed class FaultTests +{ + private enum OrderFault { NotFound, NameTaken } + + private static string Consumer(string member) => + $$""" + using CodoMetis.TypeKit; + + public enum OrderFault { NotFound, NameTaken } + + public static class Checks + { + public static {{member}} + } + """; + + [Theory] + [InlineData("bool Same(Fault fault) => fault == OrderFault.NotFound;")] + [InlineData("bool Same(Fault fault) => fault != OrderFault.NotFound;")] + [InlineData("bool Same(Fault fault) => OrderFault.NotFound == fault;")] + [InlineData("bool Same(Fault fault) => OrderFault.NotFound != fault;")] + [InlineData("bool Same(Fault fault) => fault.Equals(OrderFault.NotFound);")] + public void Comparing_a_fault_with_a_bare_reason_is_a_compile_error_naming_Reason(string member) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + + compilation.ErrorIds().ShouldBe(["CS0619"], compilation.Problems()); + compilation.Problems().ShouldContain("fault.Reason"); + } + + [Theory] + [InlineData("bool Same(Fault fault) => fault.Reason == OrderFault.NotFound;")] + [InlineData("bool Same(Fault fault, Fault other) => fault == other;")] + [InlineData("bool Same(Fault fault) => fault == Fault.Of(OrderFault.NotFound, \"Order 7 is gone.\");")] + public void Comparing_the_reason_or_two_faults_compiles(string member) => + ConsumerCompilation.Of(Consumer(member)).Problems().ShouldBeEmpty(); + + [Fact] + public void Two_faults_are_equal_by_reason_and_detail() + { + Fault.Of(OrderFault.NotFound, "Order 7 is gone.").ShouldBe(Fault.Of(OrderFault.NotFound, "Order 7 is gone.")); + Fault.Of(OrderFault.NotFound, "Order 7 is gone.").ShouldNotBe(Fault.Of(OrderFault.NotFound)); + Fault.Of(OrderFault.NotFound, "Order 7 is gone.").Reason.ShouldBe(OrderFault.NotFound); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs b/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs index 8729e10..541ce49 100644 --- a/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs +++ b/test/CodoMetis.TypeKit.Tests/QueryableOptionTests.cs @@ -51,6 +51,7 @@ public void A_query_binds_to_the_overload_that_takes_an_expression(string member [InlineData("Option Last(IQueryable query) => query.LastOrNone();", "FirstOrNone")] [InlineData("Option Last(IQueryable query) => query.LastOrNone(x => x > 1);", "FirstOrNone")] [InlineData("Result Last(IQueryable query) => query.LastOrError(x => x > 1, \"none\");", "FirstOrError")] + [InlineData("Option Last(IQueryable query) => query.ElementAtOrNone(^1);", "ElementAtOrNone(int)")] public void LastOrNone_and_LastOrError_on_a_query_are_compile_errors(string member, string advice) { var compilation = ConsumerCompilation.Of(Consumer(member)); @@ -64,6 +65,16 @@ public void LastOrNone_and_LastOrError_on_a_query_are_compile_errors(string memb public void Reading_a_query_on_purpose_still_has_LastOrNone() => ConsumerCompilation.Of(Consumer("Option Last(IQueryable query) => query.AsEnumerable().LastOrNone();")).Problems().ShouldBeEmpty(); + /// An index from the start is the query's own overload; one from the end, which has no query, is a compile error, and the sequence overload stays reachable on purpose. + [Fact] + public void ElementAtOrNone_from_the_start_is_the_query_s_and_from_the_end_needs_AsEnumerable() + { + var (_, fromStart) = ConsumerCompilation.Of(Consumer("Option At(IQueryable query) => query.ElementAtOrNone(2);")).TheOnlyCall(); + fromStart.ContainingType.ExtensionParameter.ShouldNotBeNull().Type.OriginalDefinition.ToDisplayString().ShouldBe("System.Linq.IQueryable"); + + ConsumerCompilation.Of(Consumer("Option At(IQueryable query) => query.AsEnumerable().ElementAtOrNone(^1);")).Problems().ShouldBeEmpty(); + } + [Fact] public void A_query_answers_its_first_match_or_None() { diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index cfaa154..49e8f30 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -68,6 +68,7 @@ private enum Fault { TooLong } ["Option.Some(null)"] = (NullContent, () => Option.Some(null!)), ["Map to null"] = (NullContent, () => Option.Some("x").Map(_ => (string)null!)), ["Patch Map to null"] = (NullContent, () => Patch.Value("x").Map(_ => (string)null!)), + ["CatchAsync over no task"] = (NullContent, () => Result.CatchAsync(() => null!).GetAwaiter().GetResult()), ["FirstOrNone over a null"] = (NullContent, () => new string[] { null! }.FirstOrNone()), ["ToResult(null)"] = (NullContent, () => Option.Some(1).ToResult((string)null!)), ["Result.Success(null)"] = (NullContent, () => Result.Success(null!)), diff --git a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs index 0d3a4f4..ce2c220 100644 --- a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs @@ -113,6 +113,30 @@ public async Task A_task_as_the_value_is_refused_before_the_delegate_runs() ran.ShouldBeFalse(); } + /// + /// A delegate that returns no task is refused before anything is awaited, in every overload, as a + /// delegate that returns a null value is: it was an error holding the NullReferenceException the + /// await threw. A delegate that throws before it returns a task is still that exception as the error. + /// + [Fact] + public async Task CatchAsync_refuses_a_delegate_that_returns_no_task_and_keeps_one_that_throws_first() + { + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + (await Should.ThrowAsync(() => Result.CatchAsync(() => null!))).ParamName.ShouldBe("action"); + + // Method groups, since a lambda that only throws converts to the valued and the command form alike. + static Task ThrowsForAValue() => throw new InvalidOperationException("before any task"); + static Task ThrowsForACommand() => throw new InvalidOperationException("before any task"); + + var thrown = await Result.CatchAsync(ThrowsForAValue); + thrown.Match(_ => "", error => error.Message).ShouldBe("before any task"); + + var command = await Result.CatchAsync(ThrowsForACommand); + command.Match(() => "", error => error.Message).ShouldBe("before any task"); + } + [Fact] public void A_cancellation_is_not_an_outcome() { From 37317de12d8c10d9b992a846d9c0a8a8898bedde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:22:43 +0200 Subject: [PATCH 040/147] fix: CMTK0010 skips doc-comment crefs, counts arrays sealed and follows an implementation; CMTK0003 reaches branches and a collection's method group CMTK0010 reported `` on every documented API, since a cref names the generic's own parameter, which carries nothing; a generic name inside structured trivia is no use. An array was "not sealed"; it is sealed and concrete. A method implementing an interface's method, or overriding a base class's, without repeating the attribute let a call through the class pass; its type parameter carries the constraints of the method it implements, found through the explicit and implicit interface implementations and the overridden method, so both the call and a forwarding through it are held to the interface. CMTK0003 unwrapped an await to a single call and so missed a result task picked by a conditional or a switch expression, awaited or converted whole to a plain task, and a method group answering a collection of results converted to a delegate that returns a plain task; each branch's call is reported at the branch, and the group as a collection. Seeded: without the four changes, the three new tests fail. Co-Authored-By: Claude Fable 5.1 --- .../IgnoredOutcomeAnalyzer.cs | 80 ++++++++++++++----- .../TypeArgumentConstraintAnalyzer.cs | 54 +++++++++++-- .../IgnoredOutcomeAnalyzerTests.cs | 36 +++++++++ .../TypeArgumentConstraintAnalyzerTests.cs | 50 ++++++++++++ 4 files changed, 197 insertions(+), 23 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index ade5f5d..b47fa1f 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; @@ -25,6 +26,8 @@ namespace CodoMetis.TypeKit.Analyzers; /// silently: Task Cancel(Guid id) => orders.CancelAsync(id);, return svc.DoAsync(); /// in a method returning Task, Func<Task> f = () => svc.DoAsync();. The implicit /// conversion of a call's task is reported; an explicit (Task) cast says the drop is intended. +/// A call reached through a branch, await (dry ? CancelAsync(id) : ShipAsync(id)); or a +/// switch expression, is each branch's call, reported at the branch. /// A ValueTask<T> has no conversion to ValueTask. A method group converts the same /// way to a delegate that returns Task: Func<Guid, Task> f = orders.CancelAsync;, or /// bus.Subscribe<Guid>(orders.CancelAsync) for a Func<T, Task> parameter. A cast @@ -125,15 +128,48 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym var expression = ((IExpressionStatementOperation)context.Operation).Operation; // Only a value a call produced: an assignment or an increment is a statement for its effect. - var call = expression is IAwaitOperation { Operation: var awaited } ? awaited : Unwrap(expression); - if (call is not IInvocationOperation invocation) return; + var awaited = expression is IAwaitOperation { Operation: var inner } ? inner : null; + var dropped = Unwrap(awaited ?? expression); - // await LoadAsync().ConfigureAwait(false): the result is LoadAsync's, and so is the name. - if (invocation is { TargetMethod.Name: "ConfigureAwait", Instance: IInvocationOperation configured }) invocation = configured; + foreach (var call in Producers(dropped)) + { + // await LoadAsync().ConfigureAwait(false): the result is LoadAsync's, and so is the name. + var invocation = call is { TargetMethod.Name: "ConfigureAwait", Instance: IInvocationOperation configured } ? configured : call; + + // The statement's own call keeps the statement's type and location; a branch's call its own. + var own = ReferenceEquals(call, dropped); + var type = own ? expression.Type : awaited is null ? invocation.Type : tasks.Awaited(invocation.Type); + if (DroppedOutcome(type, invocation, symbols, tasks) is not { } outcome) continue; - if (DroppedOutcome(expression.Type, invocation, symbols, tasks) is not { } outcome) return; + context.Report(Diagnostic.Create(Rule, (own ? expression : call).Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + } + } - context.Report(Diagnostic.Create(Rule, expression.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + /// + /// The calls whose values drops as one: the call itself, or each + /// branch's call of a conditional or a switch expression, through implicit conversions and + /// null-conditional accesses. A branch that is no call, a variable or a literal, drops nothing of + /// its own. + /// + private static IEnumerable Producers(IOperation operation) + { + switch (Unwrap(operation)) + { + case IInvocationOperation invocation: + yield return invocation; + break; + case IConditionalOperation { Type: not null, WhenTrue: { } whenTrue, WhenFalse: { } whenFalse }: + foreach (var producer in Producers(whenTrue)) yield return producer; + foreach (var producer in Producers(whenFalse)) yield return producer; + break; + case ISwitchExpressionOperation chosen: + foreach (var arm in chosen.Arms) + { + foreach (var producer in Producers(arm.Value)) yield return producer; + } + + break; + } } /// @@ -190,20 +226,23 @@ private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitS { var conversion = (IConversionOperation)context.Operation; - if (!conversion.IsImplicit || conversion.Operand is not IInvocationOperation invocation) return; + if (!conversion.IsImplicit) return; - string? outcome; - if (SymbolEqualityComparer.Default.Equals(conversion.Type, tasks.Plain)) - outcome = tasks.PendingOutcome(invocation.Type, symbols) - ?? DroppedCollection(tasks.CollectionOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks); - else if (tasks.HoldsPlainTasks(conversion.Type)) - outcome = DroppedCollection(tasks.PendingOutcome(tasks.ElementOfSequence(invocation.Type), symbols), invocation, symbols, tasks); - else - return; + var toPlainTask = SymbolEqualityComparer.Default.Equals(conversion.Type, tasks.Plain); + if (!toPlainTask && !tasks.HoldsPlainTasks(conversion.Type)) return; - if (outcome is null) return; + // The call, or each branch's call where a conditional or a switch expression was converted whole. + foreach (var invocation in Producers(conversion.Operand)) + { + var outcome = toPlainTask + ? tasks.PendingOutcome(invocation.Type, symbols) + ?? DroppedCollection(tasks.CollectionOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks) + : DroppedCollection(tasks.PendingOutcome(tasks.ElementOfSequence(invocation.Type), symbols), invocation, symbols, tasks); - context.Report(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + if (outcome is null) continue; + + context.Report(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + } } /// @@ -255,7 +294,12 @@ private static void AnalyzeMethodGroup(OperationAnalysisContext context, TypeKit if (creation.Target is not IMethodReferenceOperation reference || creation.Syntax is CastExpressionSyntax) return; if (creation.Type is not INamedTypeSymbol { DelegateInvokeMethod: { } invoke } || !SymbolEqualityComparer.Default.Equals(invoke.ReturnType, tasks.Plain)) return; - if (tasks.PendingOutcome(reference.Method.ReturnType, symbols) is not { } outcome) return; + + // A method that answers a collection of results makes them on every call through the delegate. + var returned = reference.Method.ReturnType; + var outcome = tasks.PendingOutcome(returned, symbols) + ?? (tasks.CollectionOutcome(tasks.Awaited(returned), symbols) is { } element ? $"collection of {element}s" : null); + if (outcome is null) return; context.Report(Diagnostic.Create(Rule, reference.Syntax.GetLocation(), outcome, reference.Method.Name)); } diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index 8986d2b..e955725 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -28,6 +28,12 @@ namespace CodoMetis.TypeKit.Analyzers; /// [Concrete], new() for [Concrete]). Otherwise the requirement would end at /// the first forwarding parameter, and Sub<ConcreteCommand> would compile. /// +/// +/// A method that implements an interface's method, or overrides a base class's, carries the +/// constraint of the method it implements whether or not it repeats the attribute, so a call through +/// the class is held to what the interface declared. A cref in a documentation comment names +/// the generic's own parameters and is not a use; an array is sealed and concrete. +/// /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class TypeArgumentConstraintAnalyzer : DiagnosticAnalyzer @@ -66,7 +72,8 @@ public override void Initialize(AnalysisContext context) private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols) { var name = (GenericNameSyntax)context.Node; - if (name.IsUnboundGenericName) return; + // names the generic's own parameter, which carries nothing: not a use. + if (name.IsUnboundGenericName || name.IsPartOfStructuredTrivia()) return; var info = context.SemanticModel.GetSymbolInfo(name, context.CancellationToken); var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); @@ -136,7 +143,7 @@ private static void AnalyzeInferred(OperationAnalysisContext context, TypeKitSym /// What breaks of what declares, as the message's last clause. private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol argument) { - foreach (var constraint in symbols.ConstraintsOn(parameter)) + foreach (var constraint in Effective(symbols, parameter)) { if (argument is ITypeParameterSymbol forwarded) { @@ -156,17 +163,54 @@ private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParamet } } + /// Whether meets ; an array is a sealed, concrete class. private static bool Satisfies(ITypeSymbol argument, TypeArgumentConstraint constraint) => constraint switch { TypeArgumentConstraint.Abstract => argument is INamedTypeSymbol { TypeKind: TypeKind.Class, IsAbstract: true, IsStatic: false }, - TypeArgumentConstraint.Sealed => argument.TypeKind is TypeKind.Struct or TypeKind.Enum || argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Delegate, IsSealed: true, IsStatic: false }, + TypeArgumentConstraint.Sealed => argument.TypeKind is TypeKind.Struct or TypeKind.Enum or TypeKind.Array || argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Delegate, IsSealed: true, IsStatic: false }, TypeArgumentConstraint.Record => argument is INamedTypeSymbol { IsRecord: true }, - _ => argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct, IsAbstract: false, IsStatic: false }, + _ => argument.TypeKind is TypeKind.Array || argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct, IsAbstract: false, IsStatic: false }, }; /// A forwarding type parameter proves a constraint by carrying the attribute, or by a C# constraint that implies it. private static bool Proves(TypeKitSymbols symbols, ITypeParameterSymbol forwarded, TypeArgumentConstraint constraint) => - symbols.ConstraintsOn(forwarded).Contains(constraint) + Effective(symbols, forwarded).Contains(constraint) || (constraint is TypeArgumentConstraint.Sealed or TypeArgumentConstraint.Concrete && forwarded.HasValueTypeConstraint) || (constraint is TypeArgumentConstraint.Concrete && forwarded.HasConstructorConstraint); + + /// + /// The constraints a type parameter declares, with those of the parameter it implements or + /// overrides: Sender.Send<T> implementing ISender.Send<[Concrete] T> carries + /// [Concrete] whether or not it repeats the attribute, so a call through the class is held + /// to it. Without this, the requirement ended at the interface. + /// + private static IEnumerable Effective(TypeKitSymbols symbols, ITypeParameterSymbol parameter) + { + var own = symbols.ConstraintsOn(parameter); + if (parameter.DeclaringMethod is not { } method) return own; + + return own.Concat(Implemented(method.OriginalDefinition) + .Where(implemented => parameter.Ordinal < implemented.OriginalDefinition.TypeParameters.Length) + .SelectMany(implemented => Effective(symbols, implemented.OriginalDefinition.TypeParameters[parameter.Ordinal]))) + .Distinct(); + } + + /// The methods overrides or implements, explicitly or implicitly, by its declaration. + private static IEnumerable Implemented(IMethodSymbol method) + { + if (method.OverriddenMethod is { } overridden) yield return overridden; + + foreach (var explicitly in method.ExplicitInterfaceImplementations) yield return explicitly; + + if (method.ExplicitInterfaceImplementations.Length > 0 || method.IsStatic || method.DeclaredAccessibility != Accessibility.Public) yield break; + + foreach (var contract in method.ContainingType.AllInterfaces) + { + foreach (var member in contract.GetMembers(method.Name).OfType()) + { + if (member.IsGenericMethod && SymbolEqualityComparer.Default.Equals(method.ContainingType.FindImplementationForInterfaceMember(member)?.OriginalDefinition, method)) + yield return member; + } + } + } } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index d86f184..e8828a8 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -215,6 +215,42 @@ public static async Task Run(Service service, Task> pending, Cmtk0003(1, "Result", "MapAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A result task reached through a branch was silent: the ternary or the switch expression + /// that picked it, awaited or converted whole to a plain task; and a method group that answers a + /// collection of results, converted to a delegate that returns a plain task. + /// + [Fact] + public Task A_result_dropped_through_a_branch_or_a_collection_s_method_group_reports() => + Test( + """ + public static class Branches + { + public static async Task Run(Service service, bool dry, int id) + { + await (dry ? {|#0:service.CancelAsync(id)|} : {|#1:service.CancelAsync(0)|}); + await (id switch { 1 => {|#2:service.CancelAsync(1)|}, _ => {|#3:service.CancelAsync(2)|} }); + await (dry ? service.RunAsync() : Task.CompletedTask); + } + + public static Task Submit(Service service, bool draft, int id) => draft ? {|#4:service.CancelAsync(id)|} : {|#5:service.CancelAsync(0)|}; + + public static void Hold(Service service) + { + Func all = {|#6:service.SaveAllAsync|}; + Func run = service.RunAsync; + } + } + """, + Cmtk0003(0, "Result", "CancelAsync"), + Cmtk0003(1, "Result", "CancelAsync"), + Cmtk0003(2, "Result", "CancelAsync"), + Cmtk0003(3, "Result", "CancelAsync"), + Cmtk0003(4, "Result", "CancelAsync"), + Cmtk0003(5, "Result", "CancelAsync"), + Cmtk0003(6, "collection of Results", "SaveAllAsync")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// A Task of a result converts implicitly to Task, which drops the result as silently /// as a statement does: an expression-bodied member, a return, a lambda, an argument. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs index e3729da..def223b 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -258,6 +258,56 @@ public sealed class NotAParameter { } .RunAsync(TestContext.Current.CancellationToken); /// Robustness, not a guard: without CodoMetis.TypeKit there is nothing to look at, and nothing may fail. + /// + /// A cref in a documentation comment names the generic's own parameter, which carries + /// nothing, and was reported on every documented API; an array is a sealed, concrete class and + /// was "not sealed". + /// + [Fact] + public Task A_doc_comment_cref_and_an_array_argument_are_not_breaches() => + Test( + """ + /// See , , and . + public static class Documented + { + public static Box Sealed => new(); + public static Factory Concrete => new(); + public static Decider<{|#0:string[]|}, PlaceOrder>? Abstract => null; + } + """, + Cmtk0010(0, "string[]", "TCommand", "Decider", "is not an abstract class or record")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A method implementing an interface's method, or overriding a base class's, carries the + /// constraint whether or not it repeats the attribute: a call through the class was silent. + /// + [Fact] + public Task An_implementation_or_an_override_carries_the_constraint_of_what_it_implements() => + Test( + """ + public interface ISender { void Send<[Concrete] T>(T handler); } + public sealed class Sender : ISender { public void Send(T handler) { } } + public abstract class Base { public abstract void Go<[Sealed] T>(); } + public sealed class Derived : Base { public override void Go() { } } + + public static class Calls + { + public static void Go(Sender sender, Derived derived, IHandler handler, OrderHandler concrete) + { + sender.Send<{|#0:IHandler|}>(handler); + {|#1:sender.Send(handler)|}; + sender.Send(concrete); + derived.Go<{|#2:Handler|}>(); + derived.Go(); + } + } + """, + Cmtk0010(0, "IHandler", "T", "Sender.Send", "is not a concrete class, record or struct"), + Cmtk0010(1, "IHandler", "T", "Sender.Send", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "Derived.Go", "is not sealed")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_compilation_without_TypeKit_is_left_alone() => new AnalyzerTest("public sealed class Box { } public static class U { public static Box B; }", referenceTypeKit: false) From f220e345098e0fb87b0a4cab8776288128092a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:22:43 +0200 Subject: [PATCH 041/147] docs: the review's corrections, the package descriptions, and the plan's record of the findings What the review found misleading, corrected: a set is the enum as a parameter and an element too; the Index form on a query is a compile error; the packages work under Native AOT except the query helpers on an EF query; AsText() refuses a line break; CatchAsync over a null task throws; the ignoreCase overloads leave binding ordinal, so a typed header is bound as a string and parsed; the Testing messages print the error through its ToString(); the EF page names all five limitations; the Express/EXPRESS wording, the MapErrorAsync/EnsureAsync row and the "no such member" comment. Missing and added: Choose, TraverseAsync, the patch members and the new type names in the migration guide; CMTK0012 and CMTK0010's inheritance in the base README; Fault under "Not wire types" and in the JSON refusal's entry, with the absent patch; WithTitle, the command ToHttpResult(success) and [FaultStatus] on a class. The passages flagged as model-written are rewritten, the package descriptions name closed sets, and the plan's step 9c records each finding and what waits for 2.1. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 55 ++++++++++++++----- README.md | 4 +- design/plan.md | 29 ++++++++++ docs/closed-sets.md | 29 +++++----- docs/ef-core.md | 17 +++--- docs/logging-and-secrets.md | 4 +- docs/merge-patches.md | 6 +- docs/migrating-to-2.md | 11 +++- docs/native-aot.md | 16 +++--- docs/option.md | 2 +- docs/result.md | 6 +- docs/testing.md | 4 +- docs/text.md | 1 + docs/type-constraints.md | 4 ++ src/CodoMetis.TypeKit.Analyzers/README.md | 4 +- .../CodoMetis.TypeKit.AspNetCore.csproj | 2 +- src/CodoMetis.TypeKit.AspNetCore/README.md | 32 +++++++---- ...doMetis.TypeKit.EntityFrameworkCore.csproj | 2 +- .../CodoMetis.TypeKit.Generators.csproj | 2 +- src/CodoMetis.TypeKit.Generators/README.md | 19 ++++--- .../CodoMetis.TypeKit.Testing.csproj | 2 +- src/CodoMetis.TypeKit.Testing/README.md | 5 +- .../CodoMetis.TypeKit.csproj | 2 +- src/CodoMetis.TypeKit/README.md | 23 +++++--- .../Docs/ClosedSetsSamples.cs | 2 +- 25 files changed, 187 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5742dc3..8c24edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,17 +21,18 @@ this release adds, so nothing that built against 1.5.1 meets them. ### Added -- **`CodoMetis.TypeKit`**: `Result.Catch`, the one place an exception becomes data. `Catch(() => …)` - runs the call and answers `Result`, or `Result` for an action; - `Catch` catches that type only and lets any other propagate; `CatchAsync` awaits the - task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` - passes through every overload, unless it is a timeout (one over a `TimeoutException`, as - `HttpClient` throws when its `Timeout` elapses), which is the error; a delegate that returns a - `Task`, an `async` lambda among them, given to `Catch` or to its command form `Catch` - is a compile error that names `CatchAsync`, and so is a delegate that returns any value given to the - command form, which would discard it; a `ValueTask`, or a task spelled as the type argument, is - refused before the delegate runs; and a delegate that returns null throws as `Result.Success(null)` - does. +- **`CodoMetis.TypeKit`**: `Result.Catch`, which turns an exception into a result. + - `Catch(() => …)` runs the call and answers `Result`, or `Result` for an + action. `Catch` catches that type only and lets any other propagate. + - `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error too. + - An `OperationCanceledException` passes through every overload. A timeout, one over a + `TimeoutException` as `HttpClient` throws when its `Timeout` elapses, is the error. + - A delegate that returns a `Task`, an `async` lambda among them, given to `Catch` or to its command + form `Catch` is a compile error naming `CatchAsync`. So is a delegate that returns any + value given to the command form, which would discard it. + - A `ValueTask`, or a task spelled as the type argument, is refused before the delegate runs. + - A delegate that returns null throws as `Result.Success(null)` does, and so does one given to + `CatchAsync` that returns no task. - **The closed-set family**, the release's headline: `IClosedSet` in `CodoMetis.TypeKit`, generated by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static readonly` field, with constant data through a private constructor where the members carry some. The @@ -48,8 +49,8 @@ this release adds, so nothing that built against 1.5.1 meets them. own code makes beside its members has no name, and reading it throws rather than write a null. CMTK0002 covers a closed set without the generators. CMTK1021 refuses a member named as a generated member, or two whose names differ only in case - (`Open` and `open`, `Express` and `EXPRESS`), one name to `Match`'s parameters and to - `TryParse(name, ignoreCase: true)`. `Match` checks every delegate for null before it picks one, + (`Open` and `open`, `Express` and `EXPRESS`): one name to `TryParse(name, ignoreCase: true)`, and + to `Match`'s parameters where the first letter is the difference. `Match` checks every delegate for null before it picks one, and a set is a JSON dictionary key by its name. `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name, in a primitive collection and a JSON column too, through a compiled model and under Native AOT @@ -124,8 +125,9 @@ this release adds, so nothing that built against 1.5.1 meets them. a `Result` pipeline. Each checks its delegates, and `ToResultAsync` its error, before the option is known, as the `Task>` continuations do. - **`CodoMetis.TypeKit`**: `SingleOrNone`, with or without a predicate, which throws for more than one - element as `SingleOrDefault` does, and `ElementAtOrNone`, by `int` or `Index`, on a sequence and on a - query (two rows and one, asked of the provider); `SingleOrNoneAsync` in the EF Core satellite; and + element as `SingleOrDefault` does, and `ElementAtOrNone`, by `int` or `Index` on a sequence and by + `int` on a query (two rows and one, asked of the provider; an `Index` from the end on a query is a + compile error, as `LastOrNone` is); `SingleOrNoneAsync` in the EF Core satellite; and CMTK0009 names each for `SingleOrDefault`, `ElementAtOrDefault` and `SingleOrDefaultAsync`. `Transpose()` between `Option>` and `Result, TError>`, and `Option.Traverse(selector)` for an optional input validated when present. @@ -182,6 +184,29 @@ this release adds, so nothing that built against 1.5.1 meets them. ### Fixed +- **`CodoMetis.TypeKit.Generators`**: a closed set declared in two partial files listed the members of + the second as null in `All`, and the names the satellites read threw; `All` is filled after every + part's fields. Two nested sets of one simple name shared one `QualifiedName` prefix; it is the + nesting chain, `Order.Status.Open`. A non-public static field of the set's own type compiled as no + member, whose name threw and which `TryParse` never found; it is CMTK1017. The nested JSON and type + converters are sealed. +- **`CodoMetis.TypeKit.AspNetCore`**: a closed set was a string enum as a body property only; as a route + or query parameter it was a bare `string`, and in a list, an array parameter or a dictionary its + element was missing. It is the enum in every position, as a value object is described in each. Two + nested sets of one simple name shared one component; each is named after its nesting chain. +- **`CodoMetis.TypeKit`**: `ElementAtOrNone(^n)` on a query bound to the sequence overload and read every + row; it is a compile error naming `ElementAtOrNone(int)` and `AsEnumerable()`. `fault == reason` on a + `Fault` compiled through the implicit conversion and was false whenever the fault carried a + detail; the mixed comparisons and `Equals(reason)` are compile errors naming `Reason`. `CatchAsync` + given a delegate that returned no task answered an error holding the `NullReferenceException` the + await threw; it throws before anything is awaited, as `Result.Success(null)` does. +- **`CodoMetis.TypeKit.Analyzers`**: CMTK0010 reported a `` in a documentation + comment, and called an array not sealed; a cref is no use, and an array is sealed and concrete. A + method implementing an interface's method, or overriding one, without repeating the attribute let a + call through the class pass; it carries the constraint of what it implements. CMTK0003 missed a + result task picked by a conditional or a `switch` expression, awaited or converted to a plain task, + and a method group of a method answering a collection of results, converted to a delegate that + returns a plain task. - **`CodoMetis.TypeKit.AspNetCore`**: a value object bound as a route, query or header parameter is described as ASP.NET describes a parameter of the wrapped type. ASP.NET uses a type's own schema there only for a primitive, an enum, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, diff --git a/README.md b/README.md index 93fd003..5ca87fd 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ public sealed partial class OrderStatus : IClosedSet The generators add `Match` with one required delegate per member, so a member added later fails every call until it is handled, `All`, parsing by name, JSON as the name, an OpenAPI string enum and a text column in EF Core, with nothing registered per set. Beside the types that are generated: -`Patch`, the field of a JSON Merge Patch, absent, null or a value; `Result.Catch`, the one place -an exception becomes data; `AsText()` and `AsIdentifier()`, for text a person wrote and a value +`Patch`, the field of a JSON Merge Patch, absent, null or a value; `Result.Catch`, which turns an +exception into a result; `AsText()` and `AsIdentifier()`, for text a person wrote and a value another system issued; and `[Sealed]`, `[Concrete]` and `[RequireParameterlessConstructor]`, the constraints C# has no `where` for, checked by the analyzers. diff --git a/design/plan.md b/design/plan.md index 8b94f88..66f4de9 100644 --- a/design/plan.md +++ b/design/plan.md @@ -701,6 +701,35 @@ Each phase ends green, and its guards have been proven by seeding the defect - Not taken: error accumulation and a `Combine` (§9 non-goals). A stable external name for a closed-set member, apart from its C# identifier, touches parsing, JSON, EF, OpenAPI and fault identifiers at once and needs a spike first; it is 2.x. + 9c. **An outside review before the tag** (2026-10-13, a separate Opus 5.5 session over the three + commits and the library), its must-fix findings each fixed with a seeded test: + - A closed set in two partial files listed the second file's members as null in `All` (a field + initializer runs with its own part); `All` is filled by the names initializer, after every + part's fields. Two nested sets of one simple name shared a `QualifiedName` prefix and one + OpenAPI component; both are the nesting chain. A non-public static field of the set's type + was an instance that is no member; CMTK1017. The nested converters are sealed. + - A closed set was a string enum as a body property only: a route or query parameter was a bare + `string`, a container's element was missing. The transformer takes the value-object + transformer's three positions. + - `ElementAtOrNone(^n)` on a query read every row through the sequence overload; refused as + `LastOrNone` is. `fault == reason` on a `Fault` compiled and was false with a detail; + the mixed forms are refused, naming `Reason`. `CatchAsync` over a delegate returning no task + was an error holding a `NullReferenceException`; it throws before awaiting. + - CMTK0010 reported doc-comment crefs and called arrays not sealed; a method implementing or + overriding a constrained method without the attribute let a call through the class pass. + CMTK0003 missed a result task picked by a conditional or a `switch` expression and a method + group answering a collection of results: decision 50's "complete" was not, and is now + measured by those forms too. + - Documentation: the claims the review found misleading corrected (a set as a parameter, the + `Index` form on a query, "everything works" under Native AOT, `AsText()` and line breaks, + `CatchAsync` over a null task, the `ignoreCase` overloads and binding, the Testing messages' + error, the EF limitations' count), the migration guide's name list completed, `Fault` under + "Not wire types", `WithTitle`, the command `ToHttpResult(success)` and `[FaultStatus]` on a + class documented, the package descriptions naming closed sets, and the prose it flagged. + - Can wait for 2.1, recorded here: the fault name is sent even for a member without a + description; `Uri.ToString()` unescapes the problem `type`; the CMTK0011 code fix on a partial + type with a primary constructor elsewhere, and its doc comment beside an attribute; CMTK0003 + over `WhenAny` of held tasks; `Choose`'s `notnull` receiver; `Patch` under `Traverse`. 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test against it, then the release. - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the diff --git a/docs/closed-sets.md b/docs/closed-sets.md index a27c6f1..a4590a1 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -1,12 +1,12 @@ # Closed sets -An `enum` names its members and nothing more: C# never proves a `switch` over one complete, a member -carries no data, and on the wire it is a number unless a converter says otherwise. A closed set, a +An `enum` names its members and nothing more: C# only warns when a `switch` expression over one +misses a member and says nothing for a `switch` statement, a member carries no data, and on the wire +it is a number unless a converter says otherwise. A closed set, a smart enum, is a sealed class whose every instance is a named member declared as a field of it, with whatever data and behaviour the members need; the generators add the rest. The [generators' README](../src/CodoMetis.TypeKit.Generators/README.md#closed-sets) is its reference; -this page is about using one: declaring it, deciding by member, on the wire, in the database, from -configuration and in logs. The examples import `CodoMetis.TypeKit.ClosedSets`. +this page is about using one. The examples import `CodoMetis.TypeKit.ClosedSets`. ## Declaring one @@ -37,7 +37,7 @@ string name = Shipping.Express.Name; // "Express" string qualified = Shipping.Express.QualifiedName; // "Shipping.Express" string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons -bool known = Shipping.TryParse("Drone", out _); // false, names compare ordinally +bool known = Shipping.TryParse("Drone", out _); // false, no such member bool typed = Shipping.TryParse("express", ignoreCase: true, out _); // true, when asked: a header or a query string as a client typed it Shipping.Parse("Drone"); // FormatException naming Shipping and member, never the name ``` @@ -45,15 +45,16 @@ Shipping.Parse("Drone"); // FormatExce `All` holds the members in declaration order, read-only: no cast to `IList` or an array writes it. `QualifiedName` keeps two sets that share a member name apart in one log or one client. `ToString()` is the name, unless the set writes its own, which is kept, for a label, while the name stays what JSON, parsing and the column use. `Parse` and -`TryParse` compare names ordinally; with `ignoreCase: true` they accept any casing, for what a client -typed, while JSON, the column, binding and `IParsable` stay ordinal, since what is stored or sent is -the name as it is. Two members whose names differ only in case are refused (CMTK1021), so there is one -member to find. +`TryParse` compare names ordinally, and with `ignoreCase: true` accept any casing. JSON, the column, +`IParsable` and a bound route or query parameter stay ordinal, since what is stored or sent is the +name as it is: a header or a query string a client typed in any casing is bound as a `string` and +parsed with `ignoreCase: true`. Two members whose names differ only in case are refused (CMTK1021), +so there is one member to find. ## Deciding by member `Match` takes one delegate per member, each named after it, and a member added later fails every -call until it is handled, which a `switch` over an `enum` never does: +call until it is handled, where a `switch` over an `enum` at most warns: ```csharp string label = shipping.Match( @@ -84,9 +85,9 @@ A member is its name, as a value and as a dictionary key, and reads back as the naming policy renames it, since a renamed name would not read back. A JSON `null` reads as `null`, as for any class, unless the serializer's `RespectNullableAnnotations` refuses it for a property that is not nullable. In the OpenAPI document, with `AddTypeKit()`, a set is a string enum of its names, -`{"enum":["Standard","Express","Pickup"],"type":"string"}`, with nothing registered per set. A -minimal-API route or query parameter binds through the generated `TryParse`, and an unknown name is -a 400; a set declared in the same project as the endpoint trips ASP0020 on a route parameter, as a +`{"enum":["Standard","Express","Pickup"],"type":"string"}`, as a property, a parameter or a +collection's element, with nothing registered per set. A minimal-API route or query parameter binds +through the generated `TryParse`, ordinally, and an unknown name is a 400; a set declared in the same project as the endpoint trips ASP0020 on a route parameter, as a value object does ([why, and the pragma](../src/CodoMetis.TypeKit.AspNetCore/README.md#a-route-parameter-declared-in-the-same-project)). @@ -165,7 +166,7 @@ A declaration they cannot complete fails the build, naming it; the generators' | is not `sealed`: a set with subtypes is not closed | CMTK1014 | | declares no member | CMTK1015 | | has a constructor that is not private | CMTK1016 | -| has another public static member of its own type: a property, or a field that is not `readonly` | CMTK1017 | +| has another static member of its own type: a property, a field that is not `readonly`, or a field that is not public, an instance that is no member | CMTK1017 | | is generic, or nested in a generic type | CMTK1018 | | has a member not created with `new` where it is declared: `Legacy = Open` would give one instance two names | CMTK1019 | | is a record: `with` would copy a member into an instance that is none | CMTK1020 | diff --git a/docs/ef-core.md b/docs/ef-core.md index a606e2a..0a0f2e9 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -178,13 +178,16 @@ Option discount = read.Discount.ToOption(); // None EF Core runs under Native AOT only through a compiled model and precompiled queries, and `UseTypeKit()` works there: value objects map through the compiled model, `.Value` translates in precompiled queries, a value-object parameter binds, and a key over an integer is generated by the -database, in the native binary. Three limitations of EF's own, described in the -[README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#compiled-models-and-native-aot): a -struct value object that wraps a reference type cannot be a property of a compiled model, since EF -cannot write its sentinel, so such a value object on an entity is a `sealed partial record`; a sealed -entity class does not compile in precompiled queries; and precompiled queries need the converter -`UseTypeKit()` composes, not one named in `HasConversion`. The commands and the project settings are -in [Native AOT](native-aot.md#ef-core). +database, in the native binary. Five limitations of EF's own are described in the +[README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#compiled-models-and-native-aot). Two +a single project meets first: precompiled queries compile their project without the generators' +bodies, so the queries live in a project that references the one declaring the value objects, and a +collection of converted elements, a `List`, cannot be read by a precompiled query. Three +reach a value object: a struct value object that wraps a reference type cannot be a property of a +compiled model, since EF cannot write its sentinel, so such a value object on an entity is a `sealed +partial record`; a sealed entity class does not compile in precompiled queries; and precompiled +queries need the converter `UseTypeKit()` composes, not one named in `HasConversion`. The commands +and the project settings are in [Native AOT](native-aot.md#ef-core). ## SQLite in tests diff --git a/docs/logging-and-secrets.md b/docs/logging-and-secrets.md index 1679ce8..e88c772 100644 --- a/docs/logging-and-secrets.md +++ b/docs/logging-and-secrets.md @@ -32,8 +32,8 @@ string record = new Session(token, "Ann").ToString(); // Sessi string json = JsonSerializer.Serialize(token); // "tok-123", a conversion, not display ``` -What still carries the value is what must: its JSON, the type converter and the database column, -which are conversions; and the debugger, which shows `ToString()` but expands `Value`. A `Session` +Still carrying the value, since they are conversions and not display: its JSON, the type converter +and the database column; and the debugger, which shows `ToString()` but expands `Value`. A `Session` written as JSON carries the token, so the shape that is logged is not the shape that is sent. ## Serilog diff --git a/docs/merge-patches.md b/docs/merge-patches.md index b3f9b89..286f800 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -157,9 +157,9 @@ The body binds as any other: each property of `ContactPatch` is read by its patc request leaves out is `Absent`. With `AddTypeKit()`, the document describes a patch property as its argument's schema, inlined rather than as a component of its own, admitting `null`, and never required, in OpenAPI 3.1 and 3.0. Null joins whatever restricts the values: the type, an enum's list -(a closed set's names, an enum written as strings), or a branch beside the argument's own branches, -so a validator accepts null and judges every other value as it judges the argument's. A value object inside a patch is described through the type it -wraps, so `email` is a `string`. This is not ASP.NET Core's JSON Patch (RFC 6902, +(a closed set's names, an enum written as strings), or a branch beside the argument's own branches. +A validator therefore accepts null and judges every other value as it judges the argument's. A value +object inside a patch is described through the type it wraps, so `email` is a `string`. This is not ASP.NET Core's JSON Patch (RFC 6902, `application/json-patch+json`), which is a list of operations. ## Source-generated contexts and Native AOT diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md index 41729f4..f451e0f 100644 --- a/docs/migrating-to-2.md +++ b/docs/migrating-to-2.md @@ -76,12 +76,21 @@ new member is an instance member, nothing is ambiguous and yours is silently no | `Option` | `MapAsync`, `BindAsync`, `TapAsync` (instance members), `Traverse` | | `Task>` | `MapAsync`, `BindAsync`, `FilterAsync`, `TapAsync`, `ToResultAsync` | | `Option>`, `Result, TError>` | `Transpose` | -| a sequence | `SingleOrNone`, `ElementAtOrNone` | +| a sequence | `SingleOrNone`, `ElementAtOrNone`, `Choose`, `TraverseAsync` | +| a sequence of options | `Choose` | +| `Patch` | `Map`, `Traverse` | | a query | `FirstOrNone`, `FirstOrError`, `SingleOrNone`, `ElementAtOrNone` | | an EF Core query | `FirstOrNoneAsync`, `SingleOrNoneAsync`, in EF Core's namespace | | `string` | `AsText`, `AsIdentifier` | | `Result` | `Catch`, `CatchAsync` | +2.0 also adds types to the `CodoMetis.TypeKit` namespace: `Patch` and `Patch`, `PatchState`, +`Fault` and `Fault`, `FaultKind`, `IFault`, `IFaultSet`, `TextFault`, +`AbstractAttribute`, `SealedAttribute`, `RecordAttribute`, `ConcreteAttribute`, +`RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`, and `IClosedSet` to +`CodoMetis.TypeKit.ClosedSets`. A type of yours with one of these names, imported beside the +package's namespace, makes a use ambiguous (CS0104): qualify one of them, or alias yours. + ## Fault responses Nothing to change. `AddFaultResponses(fault => …)` maps an enum as it did, and answers with diff --git a/docs/native-aot.md b/docs/native-aot.md index d49536e..11be1c9 100644 --- a/docs/native-aot.md +++ b/docs/native-aot.md @@ -1,12 +1,12 @@ # Native AOT -Everything in the packages works in a trimmed or Native AOT application, and every package builds -with the trim and AOT analyzers on, warnings as errors. That cannot see the code the generators weave -into a consumer, or tell that a run-time path still works, so the consumer smoke test publishes two -consumers with `PublishAot` on every CI build, a web host with JSON and OpenAPI and a console -application with EF Core's compiled model, and runs the native binaries. This page is what Native AOT -asks of an application that uses value objects, in one place; each package README has its own -section. +The packages work in a trimmed or Native AOT application, with one exception, the query helpers on +an EF Core query ([EF Core](#ef-core) below), and every package builds with the trim and AOT analyzers +on, warnings as errors. The analyzers cannot see the code the generators weave into a consumer, nor +tell that a run-time path still works, so the consumer smoke test publishes two consumers with +`PublishAot` on every CI build, a web host with JSON and OpenAPI and a console application with EF +Core's compiled model, and runs the native binaries. This page is what Native AOT asks of an +application that uses value objects; each package README has its own section. ## The project @@ -117,7 +117,7 @@ string bound = host.Match(h => h.Value, fault => fault.ToString()); ``` The reflection-based binder, outside Native AOT, binds a value object through its generated -`TypeConverter`; the line above works in both, which is the point of writing it. A closed set is the +`TypeConverter`; the line above works in both. A closed set is the same: the reflection-based binder reads it through its type converter, the source generator does not, so bind its name as a `string` and `Parse` it. diff --git a/docs/option.md b/docs/option.md index ba17638..a3ba0c6 100644 --- a/docs/option.md +++ b/docs/option.md @@ -127,7 +127,7 @@ Every public member, on `Option`, on the static class `Option`, and as extens | `FirstOrNone()`, `FirstOrNone(predicate)` | `IEnumerable`, `IQueryable` | the first element, or the first matching one; `None` for none. On a query the provider runs the predicate and returns one row | | `LastOrNone()`, `LastOrNone(predicate)` | `IEnumerable` | the last, likewise; a compile error on a query, which has no last row until it is ordered | | `SingleOrNone()`, `SingleOrNone(predicate)` | `IEnumerable`, `IQueryable` | the only element, or `None` for none; throws for more than one. A query is asked for two rows | -| `ElementAtOrNone(index)` | `IEnumerable` (an `int` or an `Index`), `IQueryable` (an `int`) | the element at the index, or `None` past either end | +| `ElementAtOrNone(index)` | `IEnumerable` (an `int` or an `Index`), `IQueryable` (an `int`) | the element at the index, or `None` past either end; an `Index` from the end on a query is a compile error | | `GetValueOrNone(key)` | `IReadOnlyDictionary` | `Option` | | `Choose(selector)` | `IEnumerable` | the values the selector answers `Some` for, in order and lazily: a filter and a map in one | | `Choose()` | `IEnumerable>` | the values of the options that hold one, in order and lazily | diff --git a/docs/result.md b/docs/result.md index 5742632..b8177a3 100644 --- a/docs/result.md +++ b/docs/result.md @@ -178,8 +178,7 @@ Result, CheckoutFault> found = await ids.TraverseAsync(check ## An exception as an outcome A call that throws has no way into a `Result` except a `try`/`catch` written by hand. `Result.Catch` -runs the call and answers its value or the exception as the error, the one place an exception becomes -data: +runs the call and answers its value or the exception as the error: ```csharp Result url = Result.Catch(() => new Uri(text)); // Error: UriFormatException @@ -263,7 +262,8 @@ extensions in the `CodoMetis.TypeKit` namespace. `T` and `TError` are `notnull` | `Tap(action)`, `TapError(action)` | both | runs the action on that branch, hands the result on | | `Zip(b, …, selector)` | `Result` with two to six results | one value from all, or the first error | | `Select`, `SelectMany` | `Result` | query syntax over `Map` and `Bind` | -| `MapAsync`, `BindAsync`, `MapErrorAsync`, `RecoverAsync`, `EnsureAsync`, `TapAsync`, `TapErrorAsync` | both, and `Task>` | the same, taking or continuing a task; the chain is awaited once | +| `MapAsync`, `BindAsync`, `RecoverAsync`, `TapAsync`, `TapErrorAsync` | both, and `Task>` | the same, taking or continuing a task; the chain is awaited once | +| `MapErrorAsync`, `EnsureAsync` | `Task>`; `EnsureAsync` on `Task>` | `MapError` and `Ensure`, once the result is known | Equality is a record struct's. `ToString()` prints the state and never the value or the error, so a result is safe to log; the debugger shows both. diff --git a/docs/testing.md b/docs/testing.md index 12b1cb0..cdb805f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -51,8 +51,8 @@ string failure = Result.Catch(() => ProductCode.Create("abc").ShouldBeSuccess()) A failure is a `TypeKitAssertionException`, which every framework reports as the test's failure, so the package depends on nothing but `CodoMetis.TypeKit`; the assertions sit beside xUnit's `Assert`, -Shouldly or whatever the test already uses. A failure names the error, which is what a test needs -to see, and never prints a value, as `Option` and `Result` never do. An uninitialized result fails +Shouldly or whatever the test already uses. A failure prints the error through its `ToString()`, +which is what a test needs to see, and never prints a value, as `Option` and `Result` never do. An uninitialized result fails every assertion, saying it is a default instance. A request's `Patch` fields say which of them the request mentioned, and the assertions say the diff --git a/docs/text.md b/docs/text.md index d20cd5a..0f599a7 100644 --- a/docs/text.md +++ b/docs/text.md @@ -17,6 +17,7 @@ using them: which one, where, and inside a value object. The examples import `Co | Folds every space separator to an ordinary space | yes | no | | Composes to NFC, so one spelling per name | yes | no, since an issued value is compared against what its issuer sends next time | | Refuses a character that renders as nothing | yes | yes | +| A line break, a tab or another control character | refused as `Hidden`: a value is one line, so a multi-line comment is split into lines first, or stays the `string` it is | refused | | The two zero-width joiners | kept: they spell Persian, Arabic and Indic scripts and hold an emoji sequence together | refused: no issuer needs one | | A variation selector | kept after the character it varies (an emoji's presentation, an ideograph's variant); elsewhere it selects nothing and is refused | the same | | A value of joiners, variation selectors and combining marks alone | refused: it renders no character of its own | refused | diff --git a/docs/type-constraints.md b/docs/type-constraints.md index e27388f..1f32190 100644 --- a/docs/type-constraints.md +++ b/docs/type-constraints.md @@ -69,6 +69,10 @@ public abstract class Sound<[Abstract] TEvent> : Aggregate; public sealed class Sealed : Aggregate where T : struct; // struct proves [Sealed], and [Concrete] ``` +A method that implements an interface's method, or overrides a base class's, carries the constraint +of the method it implements whether or not it repeats the attribute, so a call through the class is +held to what the interface declared. Repeat the attribute anyway, so the declaration says so. + `struct` proves `[Sealed]` and `[Concrete]`, and `new()` proves `[Concrete]`, so a parameter under those C# constraints needs no attribute. The open generic inside its own body and an unbound `typeof(Aggregate<,>)` are never reported. Reflection, `MakeGenericType`, is out of reach, and the diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 832317a..b4cbf89 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -14,14 +14,14 @@ appears to: an instance that passed no factory, or a value object that was never |---|---|---| | CMTK0001 | Error | `default`, `default(T)`, `new T()`, `new()` and `new T { }` of a value object, an `Option`, a `Result`, or a struct marked `[RequireCustomInitialization]`. Also through a type parameter whose constraints make it one of those. A default that is only compared (`id == default`, `id.Equals(default)`, `ThrowIfEqual(id, default)`, `Assert.NotEqual(default, id)`) is a guard and is not reported, nor is one assigned to an `out` parameter of a Try method that returns `bool`. | | CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | -| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection of them that the statement made, dropped by a statement, awaited or not, by converting its `Task` to a plain `Task`, a method group's included, by waiting for it, or by a loop that never reads it. `_` says the drop is meant. | +| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection of them that the statement made, dropped. The drops: a statement, awaited or not; a conversion of its `Task` to a plain `Task`, a method group's included; `Wait`; a loop that never reads it; a branch of a conditional or a `switch` expression that picked the call. `_` says the drop is meant. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core satellite. | | CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero is not reported. | | CMTK0006 | Warning | A field or auto-property of such a type in a class that no initializer, `required` or constructor sets, or a `required` one that a `[SetsRequiredMembers]` constructor does not set. | | CMTK0007 | Info | `FromKnownGood` given a value that comes straight from a parameter. | | CMTK0008 | Warning | The wrapped values of two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves through `Equals(object)`. | | CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind. | -| CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call or a method group, written or inferred. A type parameter forwarded without the attribute is a breach too. | +| CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call or a method group, written or inferred. A type parameter forwarded without the attribute is a breach too, and a method implementing an interface's method, or overriding one, carries the constraint of what it implements. | | CMTK0011 | Error | A concrete type that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. The code fix writes or changes the constructor. | | CMTK0012 | Error | A closed set's `Match` given an argument by position, which binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | diff --git a/src/CodoMetis.TypeKit.AspNetCore/CodoMetis.TypeKit.AspNetCore.csproj b/src/CodoMetis.TypeKit.AspNetCore/CodoMetis.TypeKit.AspNetCore.csproj index 94fb0e9..e8fecc1 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/CodoMetis.TypeKit.AspNetCore.csproj +++ b/src/CodoMetis.TypeKit.AspNetCore/CodoMetis.TypeKit.AspNetCore.csproj @@ -2,7 +2,7 @@ - ASP.NET Core integration for CodoMetis.TypeKit: OpenAPI schemas for value objects, and a Result as an HTTP response with problem details. + ASP.NET Core integration for CodoMetis.TypeKit: OpenAPI schemas for value objects, closed sets and merge patches, and a Result as an HTTP response with problem details and fault responses a client can act on. aspnetcore;openapi;json-schema;minimal-api;value-object;strongly-typed-id;ddd;native-aot;result;problem-details diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index db6ea54..3eaee4a 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -20,8 +20,9 @@ value objects references `CodoMetis.TypeKit.Generators`. builder.Services.AddOpenApi(options => options.AddTypeKit()); ``` -`AddTypeKit()` adds one schema transformer, and names the component of a nested value object (see -below). Nothing is registered per type and no assembly is scanned, and its position among the +`AddTypeKit()` adds a schema transformer each for value objects, closed sets and merge patches, an +operation transformer for the fault responses, and names the component of a nested value object or +closed set (see below). Nothing is registered per type and no assembly is scanned, and its position among the document's other transformers does not matter. It lives in `Microsoft.Extensions.DependencyInjection`, beside `AddOpenApi`, which a web project imports implicitly. @@ -109,11 +110,15 @@ no component at all), and both parameters are a bare `string`. another transformer, is not overwritten; only what it leaves open is filled in. **A merge patch.** A property typed `Patch` (CodoMetis.TypeKit, a JSON Merge Patch field) is -described as `T`'s schema, inlined where the property is rather than as a `PatchOfstring` component, -never required, since absent is a state, and admitting null, since null is the clear: beside the -type, in an enum's list of values, or as a branch beside the argument's own. A value object -inside a patch is described through the type it wraps, which is also what a source-generated host -can do: the patch's converter hides the value object from its context. +described as `T`'s schema: + +- inlined where the property is, rather than as a `PatchOfstring` component; +- never required, since absent is a state; +- admitting null, since null is the clear: beside the type, in an enum's list of values, or as a + branch beside the argument's own. + +A value object inside a patch is described through the type it wraps, which is also what a +source-generated host can do: the patch's converter hides the value object from its context. ## A Result as a response @@ -185,8 +190,9 @@ builder.Services.AddFaultResponses(fault => fault switch }); ``` -A bare status converts to a `FaultResponse`, so the arms that need nothing else stay a number. Every -member is still mapped and checked when this is called. The document's problem response for a +A bare status converts to a `FaultResponse`, so the arms that need nothing else stay a number; +`WithTitle("…")` sets the `title` the same way. Every member is still mapped and checked when this +is called. The document's problem response for a status lists the problem types answered with it in its description: `Payment Required. Problem types: https://example.com/problems/declined`. @@ -256,8 +262,9 @@ An MVC action says it with an attribute, naming the member, since an attribute t public Results, FaultProblem> Open(NewAccount body) => accounts.Open(body).ToHttpResult(); ``` -A status outside 400 to 599 is refused where it is set. A status for a fault type the endpoint does -not answer with, or an attribute naming no member, stops the host from starting, naming the endpoint. +On a controller class, the attribute applies to every action of it. A status outside 400 to 599 is +refused where it is set. A status for a fault type the endpoint does not answer with, or an attribute +naming no member, stops the host from starting, naming the endpoint. ### The success the endpoint chooses @@ -270,7 +277,8 @@ app.MapPost("/v2/accounts", (NewAccount body) => accounts.Open(body).ToHttpResul The operation lists `201` with the account as its body, beside the problem responses, and the response carries the `Location` header. A result of the application's own, one that sets an ETag, -takes the same place. +takes the same place. A `Result` takes a factory without a value, `ToHttpResult(() => +TypedResults.Accepted("/jobs/7"))`. ### What the host adds to a problem diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj b/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj index fad1b96..73dacbb 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/CodoMetis.TypeKit.EntityFrameworkCore.csproj @@ -2,7 +2,7 @@ - EF Core integration for CodoMetis.TypeKit: value-object converters by convention, and .Value in LINQ queries. + EF Core integration for CodoMetis.TypeKit: value-object and closed-set converters by convention, and .Value in LINQ queries. efcore;entity-framework-core;value-object;strongly-typed-id;value-converter;linq;postgresql;ddd;native-aot diff --git a/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj b/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj index 8b091cc..0c9787f 100644 --- a/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj +++ b/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj @@ -6,7 +6,7 @@ Metalama.Framework reference flows on purpose (spikes/FabricSpike). --> - Compile-time generation of strongly-typed value objects for CodoMetis.TypeKit, built on Metalama. + Compile-time generation of strongly-typed value objects and closed sets (smart enums) for CodoMetis.TypeKit, built on Metalama. value-object;strongly-typed-id;primitive-obsession;code-generation;metalama;ddd;domain-driven-design;system-text-json;iparsable;validation diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 2a837e0..3f71cae 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -173,8 +173,8 @@ public sealed partial record ApiKey : IValue } ``` -- **What still carries the value is what must**: its JSON, the type converter and the database column, - which are conversions, not display; and the debugger, which shows `ToString()` but expands `Value`. +- **Still carrying the value**, since they are conversions and not display: its JSON, the type + converter and the database column; and the debugger, which shows `ToString()` but expands `Value`. - **Serilog** reads the public `Value` where it destructures (`{@Key}`, or a record holding one), past `ToString()`. [CodoMetis.TypeKit.Serilog](https://www.nuget.org/packages/CodoMetis.TypeKit.Serilog) logs every value object as what it prints instead. @@ -229,9 +229,10 @@ position would follow the declaration order and swap branches when two members a analyzer requires every argument named (CMTK0012), and its code fix names them. `Match` checks every delegate for null before it picks one. `Parse` and the JSON converter refuse an unknown name with an exception naming the set, never the input, and a set is a dictionary key in JSON by its name too. -`Parse(name, ignoreCase: true)` and `TryParse(name, ignoreCase: true, out member)` accept any casing, -for a header or a query string as a client typed it; JSON, the type converter, binding and -`IParsable` stay ordinal, since what is stored or sent is the name as it is. +`Parse(name, ignoreCase: true)` and `TryParse(name, ignoreCase: true, out member)` accept any casing. +JSON, the type converter, `IParsable` and a bound route or query parameter stay ordinal, since what +is stored or sent is the name as it is: a header or a query string a client typed in any casing is +bound as a `string` and parsed with `ignoreCase: true`. `IParsable` is implemented explicitly, for generic code that parses through `T.Parse`, and a type converter reads and writes the name, so the configuration binder, MVC and anything else that reads a string through `TypeDescriptor` bind a set. The binder's source generator reads no type @@ -244,8 +245,8 @@ and the ASP.NET Core satellite answers it by kind. Each member is initialized with `new` where it is declared (CMTK1019), and the constructors are private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated -member, nor differ from another's only in case (`Open` and `open`), which `Match`'s parameters and -`TryParse(name, ignoreCase: true)` cannot tell apart, CMTK1021. A `ToString()` +member, nor differ from another's only in case (`Open` and `open`), one name to +`TryParse(name, ignoreCase: true)`, CMTK1021. A `ToString()` written by hand is kept, and the name is still what JSON, parsing and the column use; so is an overload of a generated method with another signature, such as `Parse(int rank)`. Any other generated member written by hand is CMTK1011. An instance the set's own code creates beside its members has no name, so reading its name throws rather than write a @@ -349,11 +350,11 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1014 | A closed set not declared `sealed`: a set with subtypes is not closed. | | CMTK1015 | A closed set with no member: a member is a `public static readonly` field of the set's own type. | | CMTK1016 | A closed set with a constructor that is not private, so an instance beside its members could be made. | -| CMTK1017 | A closed set with a public static member of its own type that is not a readonly field: a property, or a field that is not `readonly`. | +| CMTK1017 | A closed set with a static member of its own type that is not a public readonly field: a property, a field that is not `readonly`, or a field that is not public, which is an instance that is no member. | | CMTK1018 | A closed set that is generic or nested in a generic type. | | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | -| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose name differs from another member's only in case: one name to `Match`'s parameters, which lower-case the first letter, and to `TryParse(name, ignoreCase: true)`. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose name differs from another member's only in case: one name to `TryParse(name, ignoreCase: true)`, and to `Match`'s parameters where the first letter is the difference. | The analyzers that come with the base package add CMTK0001 to CMTK0012; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). diff --git a/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj b/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj index 3b745fd..dd89959 100644 --- a/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj +++ b/src/CodoMetis.TypeKit.Testing/CodoMetis.TypeKit.Testing.csproj @@ -2,7 +2,7 @@ - Test assertions for CodoMetis.TypeKit's Option and Result: hand a test the value or the error it expects, or fail with what the outcome was instead. No test framework dependency. + Test assertions for CodoMetis.TypeKit's Option, Result and Patch: hand a test the value or the error it expects, or fail with what the outcome was instead. No test framework dependency. testing;assertions;option;result;xunit;nunit;mstest diff --git a/src/CodoMetis.TypeKit.Testing/README.md b/src/CodoMetis.TypeKit.Testing/README.md index e02da1a..76f2166 100644 --- a/src/CodoMetis.TypeKit.Testing/README.md +++ b/src/CodoMetis.TypeKit.Testing/README.md @@ -36,7 +36,8 @@ patch.Name.ShouldBeAbsent(); // not in the document - **No test framework.** A failure is a `TypeKitAssertionException`, and every framework reports an exception from a test as its failure. The package depends on `CodoMetis.TypeKit` alone. - **A failure says what the outcome was**: `Expected a success, but the Result is - the error NoAt.` It names the error, which is what a test needs to see, and never prints a value, as - `Option`, `Result` and `Patch` themselves never do. + the error NoAt.` It prints the error through its `ToString()`, which is what a test needs to see, so + an exception's message or a `Fault`'s detail appears; it never prints a value, as `Option`, `Result` + and `Patch` themselves never do. - **An uninitialized result fails every assertion**, saying it is a default instance, neither a success nor an error. diff --git a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj index c467245..633d7e7 100644 --- a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj +++ b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj @@ -5,7 +5,7 @@ Metalama, directly or transitively; MetalamaBoundaryTests fails the build if it does. --> - Option and Result types that cannot be misused, and the contracts for strongly-typed value objects. + Option and Result types that cannot be misused, the contracts for strongly-typed value objects and closed sets (smart enums), a merge patch's field, and the text guards. option;result;maybe;railway-oriented-programming;functional;error-handling;value-object;strongly-typed-id;primitive-obsession;ddd;domain-driven-design;native-aot diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 1c16c58..ec1f1f3 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -172,8 +172,7 @@ returns, and what it throws reaches that `await`. A callback that returns a `Val overload and not wait for it, and `MapAsync` would hold the `ValueTask` as the value. **Exceptions as outcomes.** A call that throws has no way into a `Result` except a `try`/`catch` -written by hand. `Result.Catch` runs the call and answers its value, or the exception as the error: -the one place an exception becomes data. +written by hand. `Result.Catch` runs the call and answers its value, or the exception as the error. ```csharp Result parsed = Result.Catch(() => int.Parse("42")); // Success: 42 @@ -213,7 +212,8 @@ description per member, from which the host derives each status. The one wire type here is `Patch`, a merge patch's field (below). -Neither `Option` nor `Result` serializes. System.Text.Json refuses both, in both directions, with a +Neither `Option` nor `Result` serializes, nor does `Fault`, which is matched to a response as +the result holding it is. System.Text.Json refuses them, in both directions, with a `NotSupportedException` that names the type and the alternative: ```csharp @@ -448,10 +448,13 @@ They arrive with this package, so whoever can see the interfaces gets the guard: `ids.FirstOrDefault()` or `ProductCode.TryFrom(s).OrDefault()`. `FirstOrNone()`, `GetValueOrNone()` and `Or(fallback)` say what happens then. - **CMTK0010**: a type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, - `[Record]` or `[Concrete]`, the constraints C# has no `where` for, at any closed use. + `[Record]` or `[Concrete]`, the constraints C# has no `where` for, at any closed use. A method + implementing an interface's method, or overriding one, carries the constraint of what it implements. - **CMTK0011**: a type under an interface or base class marked `[RequireParameterlessConstructor]` without the parameterless constructor it asks for, which the host would look for by reflection. The code fix writes it. +- **CMTK0012**: a closed set's `Match` given an argument by position, where reordering two members + would swap their branches and compile. The code fix names every argument. The rules also run in Razor components (`.razor`, `.cshtml`). @@ -461,8 +464,10 @@ rule table. ## Native AOT -Everything here works in a trimmed or Native AOT application, and the packages are built with the -trim and AOT analyzers on. With source-generated JSON, which Native AOT requires, list the value +This package works in a trimmed or Native AOT application, and the packages are built with the +trim and AOT analyzers on. The one exception is `FirstOrNone`, `FirstOrError`, `SingleOrNone` and +`ElementAtOrNone` on an EF Core query, which compose the query where EF cannot precompile it and throw +there; write EF's operators at the call site and take the option of the list. With source-generated JSON, which Native AOT requires, list the value objects (or the types that hold them) in your `JsonSerializerContext`, not what they wrap: the generated converters build the wrapped type's contract themselves, from a converter on your options, the type's own `[JsonConverter]`, or the serializer's built-in one. Only a value object wrapping a @@ -482,7 +487,9 @@ call instead. **Thrown:** `NotSupportedException: Option is not a wire type…`, or `Result is an outcome, not a wire type…`, when System.Text.Json reads or writes an `Option`, a `Result`, a -marker such as `Option.None()`, or a nullable of any of them. +`Fault`, a marker such as `Option.None()`, or a nullable of any of them. The same link ends +the refusal of writing an absent `Patch`, which no document can carry: mark the property +`[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]`. **Why:** without it a `Some` is written as `{}` and read back as `None`, and nothing is raised; see [Not wire types](#not-wire-types) and the [design](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/design/plan.md#9-option-and-result). @@ -499,6 +506,8 @@ there too. `Result.Error(null)`, from a `Map` or `MapError` whose selector returns null, from `FirstOrNone()` over a null element, or for a null error given to `ToResult`, `Ensure` or `FirstOrError`. A `Patch.Map` whose selector returns null throws the same: a clear is the Null state, never a value. +So does `CatchAsync` for a delegate that returns no task: a null cannot be awaited, and it is not an +exception the call threw. **Why:** an option that reports a value always has one, and a result's value and error are never null. `notnull` is not checked at run time, so the factories check it. diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs index 85ac601..9ef24f1 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/ClosedSetsSamples.cs @@ -63,7 +63,7 @@ public static (string Name, string Qualified, string All, bool Same, bool Known, string qualified = Shipping.Express.QualifiedName; // "Shipping.Express" string all = string.Join(" ", Shipping.All.Select(s => s.Name)); // "Standard Express Pickup" bool same = Shipping.Parse("Express") == Shipping.Express; // true, members are singletons - bool known = Shipping.TryParse("Drone", out _); // false, names compare ordinally + bool known = Shipping.TryParse("Drone", out _); // false, no such member bool typed = Shipping.TryParse("express", ignoreCase: true, out _); // true, when asked: a header or a query string as a client typed it // end sample From 4237abb5f835025f29852040bf6ede73366d5ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:24:59 +0200 Subject: [PATCH 042/147] docs: the comparison moves to docs/comparison.md, the README keeps the reasons to choose TypeKit "How it compares" was a quarter of the README: three wide tables and a decision table, versioned and dated, which churn on their own clock and wrap badly on the landing page. The page holds the dated intro, "Choose something else when" and the three detail tables; the README keeps the four "Choose TypeKit when" bullets and points at the page, which the Documentation table lists and the Generators README links. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +- README.md | 67 ++-------------------- docs/comparison.md | 66 +++++++++++++++++++++ src/CodoMetis.TypeKit.Generators/README.md | 2 +- 4 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 docs/comparison.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c24edb..2a1f193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,8 +143,9 @@ this release adds, so nothing that built against 1.5.1 meets them. `ShouldBeAbsent()` on a `Patch`, failing with the state the field was in and never the value. - **Documentation**: topic pages under `docs/`, linked from the root README's "Documentation" table: value objects, closed sets, `Option`, `Result`, written text, type constraints, merge patches, - JSON, EF Core, ASP.NET Core, Native AOT, logging and secrets, testing, and migrating from 1.5, - what builds or runs differently for code that built against it. Every C# block in them is compiled and its stated + JSON, EF Core, ASP.NET Core, Native AOT, logging and secrets, testing, migrating from 1.5, + what builds or runs differently for code that built against it, and the comparison with the + value-object, result and smart-enum libraries, moved out of the README with its dated tables. Every C# block in them is compiled and its stated outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete public surface, checked against the types. The package READMEs remain the reference for each package. The plan moved from `docs/plan.md` to `design/plan.md`. diff --git a/README.md b/README.md index 5ca87fd..eb6f010 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ compiled and its stated outputs are tested, as the READMEs' are. | [Logging and secrets](docs/logging-and-secrets.md) | A value that must not be printed, what still carries it, Serilog, Microsoft.Extensions.Logging, refusals that never quote the input | | [Testing](docs/testing.md) | Assertions for `Option`, `Result` and `Patch`, table-driven tests of `Create`, EF Core on SQLite, endpoints on the test server | | [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) | What builds or runs differently: CMTK0003 as an error, a query's `FirstOrNone` in the database, names that 2.0 adds, fault responses, Native AOT | +| [How it compares](docs/comparison.md) | What to choose instead for a need TypeKit does not meet, and the detail tables against the value-object, result and smart-enum libraries, versioned and dated | ## What it holds to @@ -176,12 +177,7 @@ compiled and its stated outputs are tested, as the READMEs' are. ## How it compares -Good libraries exist for both halves of TypeKit, and some needs are better met by one of them. What -follows was read from each library's source at the release named, on 28 September 2026 for value -objects, `Option` and `Result`, including the next major where one is in preview, and on 13 October -2026 for closed sets. If something here is wrong or has changed, please open an issue. - -### Choose TypeKit when +Good libraries exist for both halves of TypeKit, and some needs are better met by one of them. - You build on .NET 10 with System.Text.Json, EF Core and ASP.NET Core, and want value objects that change no contract: the JSON, the column and the OpenAPI schema stay the wrapped type's, with nothing @@ -193,62 +189,9 @@ objects, `Option` and `Result`, including the next major where one is in preview ignored result reported, no silent `{}` on the wire. - You publish with Native AOT. -### Choose something else when - -| If you need | Consider | Because | -|---|---|---| -| .NET Framework, .NET Standard or a .NET before 10 | Vogen for value objects; CSharpFunctionalExtensions, ErrorOr or FluentResults for results | TypeKit targets .NET 10 only; these ship for .NET Standard 2.0 | -| Newtonsoft.Json, Dapper, LINQ to DB, MongoDB, Orleans, ServiceStack or protobuf-net for value objects | Vogen | It ships an opt-in integration for each. TypeKit covers System.Text.Json, EF Core and ASP.NET Core | -| A plain Roslyn source generator in the build rather than Metalama | Vogen or Thinktecture.Runtime.Extensions | TypeKit's generators run on Metalama (open source, no license key) in the project that declares value objects | -| Value objects of several members, or discriminated unions | Thinktecture.Runtime.Extensions | It has `[ComplexValueObject]` and `[Union]`. A TypeKit value object wraps exactly one value; its closed sets are the smart enums | -| Several errors per failure, of typed kinds | ErrorOr | An `ErrorOr` carries a list of errors. A TypeKit `Result` carries one `TError`, which can be a list of your own | -| Errors with reasons, success messages and causes | FluentResults | Its results carry reasons, errors and successes, and an error can name what caused it | -| A functional-programming framework: `Either`, `Fin`, effects, immutable collections | LanguageExt (4.4.9; 5.0 in beta) | TypeKit's `Option` and `Result` are deliberately small, with no effect or validation types | -| Unions of arbitrary types | OneOf | `OneOf` is general-purpose; TypeKit has only `Option` and `Result` | -| A `Result` on the wire | CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its `Result`. TypeKit refuses to serialize `Option` and `Result`, by design | -| A smart enum whose JSON or column is a number, or whose wire name differs from its C# identifier | Ardalis.SmartEnum, Intellenum or Thinktecture.Runtime.Extensions | A TypeKit closed set is its member's name everywhere, and the name is the field's | -| Only your own closed set of outcomes, on .NET 11 | C#'s union types | They arrive with .NET 11 | -| Years of production use behind the library | Any of the above | TypeKit is new: 1.0.0 shipped in September 2026 | - -### Value objects in detail - -| | TypeKit 2.0.0 | Vogen 8.0.7 (9.0.0-beta.2 the same) | Thinktecture.Runtime.Extensions 10.5.0 | StronglyTypedId 1.0.0-beta08 | -|---|---|---|---|---| -| Declared by | Implementing `IValue` or `IValidatedValue<…>` on a `partial record` | `[ValueObject]` on a partial type | `[ValueObject]`, `[ComplexValueObject]` or `[SmartEnum]` on a partial type | `[StronglyTypedId]` on a partial struct | -| Validation | `Create` returns a `Result`, and every generated way in applies it | `Validate` returns a `Validation`; `From` throws | A `ValidateFactoryArguments` partial method | None | -| EF Core | `UseTypeKit()` maps every value object, nothing per type; `.Value` in a query translates to the column | A generated converter per value object, which you register; comparing with a primitive in a query is an error (VOG034) | Packages per EF Core version (8, 9, 10) | Through a template of your own | -| OpenAPI | The schema ASP.NET publishes for the wrapped type | A generated schema transformer, type and format from a built-in table; Swashbuckle support | Swashbuckle filters | None built in | -| JSON | System.Text.Json, the wrapped type's JSON byte for byte | System.Text.Json by default; Newtonsoft.Json and more, opt-in | System.Text.Json, Newtonsoft.Json, MessagePack | System.Text.Json (more through templates) | -| Targets | `net10.0` | `netstandard2.0`, .NET Framework projects included | `net8.0`, `net9.0` | `netstandard2.0` | -| Generated by | Metalama, in the declaring project | A Roslyn source generator | Roslyn source generators | A Roslyn source generator | -| License | MIT | Apache-2.0 | BSD-3-Clause | MIT | - -### Option and Result in detail - -| | TypeKit 2.0.0 | LanguageExt 4.4.9 (5.0.0-beta-77) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | -|---|---|---|---|---| -| Reaching the value | `Match`, `TryGetValue` and the combinators; no `.Value` | `Match`, `IfNone` and the like; no public `.Value` on `Option` | `.Value`, which throws on a failure | `.Value`, which returns `default` on an error | -| A `default` instance | `Option`: `None`. `Result`: throws when used, and is an error in source (CMTK0001) | `Option`: `None` | `Maybe`: none. `Result`: a success holding `default(T)` | A success holding `default(T)` | -| An ignored result | Reported (CMTK0003) | No analyzer | No analyzer of its own | No analyzer | -| System.Text.Json | Refused in both directions, rather than written as `{}` | No converter of its own | Opt-in converters | No converter for the type | -| Targets | `net10.0` | `netstandard2.0` (5.0 beta: `net10.0`) | `netstandard2.0`, `net6.0`, `net8.0` | `netstandard2.0`, `net8.0`, `net10.0` | - -### Closed sets in detail - -| | TypeKit 2.0.0 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.5.1 | -|---|---|---|---|---| -| Declared by | Implementing `IClosedSet` on a `sealed partial class`, one `public static readonly` field per member | Deriving from `SmartEnum`, each member `new("Name", value)` | `[Intellenum]` on a partial class; members as fields, `[Member]` attributes or a static constructor | `[SmartEnum]` on a partial class, each member `new("KEY")` | -| Data on a member | Properties, set through the private constructor | The subclass's own fields | One underlying value; richer data through a custom underlying type | Declared properties, which become constructor parameters | -| Deciding by member | `Match`, one required delegate per member, every argument named (CMTK0012) | `When(…).Then(…)`; nothing requires every member | A `switch` over `.Value` with generated constants; nothing requires every member | Generated `Switch` and `Map`, every member a required argument | -| Parsing by name | `Parse` and `TryParse`, ordinal; `ignoreCase: true` on request, and two members differing only in case refused at build (CMTK1021) | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, a case clash thrown at the first such lookup | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, the first declared answers | `Get` and `TryGet`, ignoring case by default; a case clash thrown at the first lookup | -| JSON | System.Text.Json, the name, nothing registered | A separate package per serializer, a converter attribute per type; the name or the value | Generated into your assembly; the value, as an `enum` writes | A separate package per serializer; the key | -| EF Core | `UseTypeKit()`, a text column of the name, nothing per set | A separate package, a convention or per model; the value | An opt-in generated converter, registered per property; the value | A separate package per EF version, `UseThinktectureValueConverters()`; the key | -| OpenAPI | `AddTypeKit()`, a string enum of the names | None built in | None built in | A Swashbuckle package | -| Analyzers | CMTK1013–1021 refuse a declaration that cannot be generated, CMTK0012 an argument by position | None | INTELLENUM001–030 | TTRESG rules, with refactorings | -| A wire name apart from the identifier | No: the name is the field's | Yes, the constructor's name | Yes, `new("STD", 1)` | Yes, the key | -| Targets | `net10.0` | `netstandard2.0`, `net6.0` to `net8.0` | `netstandard2.0` | `net8.0`, `net9.0` | -| Generated by | Metalama, in the declaring project | Nothing: a run-time base class | A Roslyn source generator | Roslyn source generators | -| License | MIT | MIT | Apache-2.0 | BSD-3-Clause | +What to choose instead for the needs TypeKit does not meet, and the detail tables against Vogen, +Thinktecture.Runtime.Extensions, StronglyTypedId, LanguageExt, CSharpFunctionalExtensions, ErrorOr, +Ardalis.SmartEnum and Intellenum, versioned and dated, are on [How it compares](docs/comparison.md). ## Built with Metalama diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..4bc3989 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,66 @@ +# How it compares + +Good libraries exist for both halves of TypeKit, and some needs are better met by one of them. What +follows was read from each library's source at the release named, on 28 September 2026 for value +objects, `Option` and `Result`, including the next major where one is in preview, and on 13 October +2026 for closed sets. If something here is wrong or has changed, please open an issue. + +The reasons to choose TypeKit are in the [README](../README.md#how-it-compares); this page is the +other side, and the detail. + +## Choose something else when + +| If you need | Consider | Because | +|---|---|---| +| .NET Framework, .NET Standard or a .NET before 10 | Vogen for value objects; CSharpFunctionalExtensions, ErrorOr or FluentResults for results | TypeKit targets .NET 10 only; these ship for .NET Standard 2.0 | +| Newtonsoft.Json, Dapper, LINQ to DB, MongoDB, Orleans, ServiceStack or protobuf-net for value objects | Vogen | It ships an opt-in integration for each. TypeKit covers System.Text.Json, EF Core and ASP.NET Core | +| A plain Roslyn source generator in the build rather than Metalama | Vogen or Thinktecture.Runtime.Extensions | TypeKit's generators run on Metalama (open source, no license key) in the project that declares value objects | +| Value objects of several members, or discriminated unions | Thinktecture.Runtime.Extensions | It has `[ComplexValueObject]` and `[Union]`. A TypeKit value object wraps exactly one value; its closed sets are the smart enums | +| Several errors per failure, of typed kinds | ErrorOr | An `ErrorOr` carries a list of errors. A TypeKit `Result` carries one `TError`, which can be a list of your own | +| Errors with reasons, success messages and causes | FluentResults | Its results carry reasons, errors and successes, and an error can name what caused it | +| A functional-programming framework: `Either`, `Fin`, effects, immutable collections | LanguageExt (4.4.9; 5.0 in beta) | TypeKit's `Option` and `Result` are deliberately small, with no effect or validation types | +| Unions of arbitrary types | OneOf | `OneOf` is general-purpose; TypeKit has only `Option` and `Result` | +| A `Result` on the wire | CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its `Result`. TypeKit refuses to serialize `Option` and `Result`, by design | +| A smart enum whose JSON or column is a number, or whose wire name differs from its C# identifier | Ardalis.SmartEnum, Intellenum or Thinktecture.Runtime.Extensions | A TypeKit closed set is its member's name everywhere, and the name is the field's | +| Only your own closed set of outcomes, on .NET 11 | C#'s union types | They arrive with .NET 11 | +| Years of production use behind the library | Any of the above | TypeKit is new: 1.0.0 shipped in September 2026 | + +## Value objects in detail + +| | TypeKit 2.0.0 | Vogen 8.0.7 (9.0.0-beta.2 the same) | Thinktecture.Runtime.Extensions 10.5.0 | StronglyTypedId 1.0.0-beta08 | +|---|---|---|---|---| +| Declared by | Implementing `IValue` or `IValidatedValue<…>` on a `partial record` | `[ValueObject]` on a partial type | `[ValueObject]`, `[ComplexValueObject]` or `[SmartEnum]` on a partial type | `[StronglyTypedId]` on a partial struct | +| Validation | `Create` returns a `Result`, and every generated way in applies it | `Validate` returns a `Validation`; `From` throws | A `ValidateFactoryArguments` partial method | None | +| EF Core | `UseTypeKit()` maps every value object, nothing per type; `.Value` in a query translates to the column | A generated converter per value object, which you register; comparing with a primitive in a query is an error (VOG034) | Packages per EF Core version (8, 9, 10) | Through a template of your own | +| OpenAPI | The schema ASP.NET publishes for the wrapped type | A generated schema transformer, type and format from a built-in table; Swashbuckle support | Swashbuckle filters | None built in | +| JSON | System.Text.Json, the wrapped type's JSON byte for byte | System.Text.Json by default; Newtonsoft.Json and more, opt-in | System.Text.Json, Newtonsoft.Json, MessagePack | System.Text.Json (more through templates) | +| Targets | `net10.0` | `netstandard2.0`, .NET Framework projects included | `net8.0`, `net9.0` | `netstandard2.0` | +| Generated by | Metalama, in the declaring project | A Roslyn source generator | Roslyn source generators | A Roslyn source generator | +| License | MIT | Apache-2.0 | BSD-3-Clause | MIT | + +## Option and Result in detail + +| | TypeKit 2.0.0 | LanguageExt 4.4.9 (5.0.0-beta-77) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | +|---|---|---|---|---| +| Reaching the value | `Match`, `TryGetValue` and the combinators; no `.Value` | `Match`, `IfNone` and the like; no public `.Value` on `Option` | `.Value`, which throws on a failure | `.Value`, which returns `default` on an error | +| A `default` instance | `Option`: `None`. `Result`: throws when used, and is an error in source (CMTK0001) | `Option`: `None` | `Maybe`: none. `Result`: a success holding `default(T)` | A success holding `default(T)` | +| An ignored result | Reported (CMTK0003) | No analyzer | No analyzer of its own | No analyzer | +| System.Text.Json | Refused in both directions, rather than written as `{}` | No converter of its own | Opt-in converters | No converter for the type | +| Targets | `net10.0` | `netstandard2.0` (5.0 beta: `net10.0`) | `netstandard2.0`, `net6.0`, `net8.0` | `netstandard2.0`, `net8.0`, `net10.0` | + +## Closed sets in detail + +| | TypeKit 2.0.0 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.5.1 | +|---|---|---|---|---| +| Declared by | Implementing `IClosedSet` on a `sealed partial class`, one `public static readonly` field per member | Deriving from `SmartEnum`, each member `new("Name", value)` | `[Intellenum]` on a partial class; members as fields, `[Member]` attributes or a static constructor | `[SmartEnum]` on a partial class, each member `new("KEY")` | +| Data on a member | Properties, set through the private constructor | The subclass's own fields | One underlying value; richer data through a custom underlying type | Declared properties, which become constructor parameters | +| Deciding by member | `Match`, one required delegate per member, every argument named (CMTK0012) | `When(…).Then(…)`; nothing requires every member | A `switch` over `.Value` with generated constants; nothing requires every member | Generated `Switch` and `Map`, every member a required argument | +| Parsing by name | `Parse` and `TryParse`, ordinal; `ignoreCase: true` on request, and two members differing only in case refused at build (CMTK1021) | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, a case clash thrown at the first such lookup | `FromName` and `TryFromName`, ordinal; `ignoreCase` on request, the first declared answers | `Get` and `TryGet`, ignoring case by default; a case clash thrown at the first lookup | +| JSON | System.Text.Json, the name, nothing registered | A separate package per serializer, a converter attribute per type; the name or the value | Generated into your assembly; the value, as an `enum` writes | A separate package per serializer; the key | +| EF Core | `UseTypeKit()`, a text column of the name, nothing per set | A separate package, a convention or per model; the value | An opt-in generated converter, registered per property; the value | A separate package per EF version, `UseThinktectureValueConverters()`; the key | +| OpenAPI | `AddTypeKit()`, a string enum of the names | None built in | None built in | A Swashbuckle package | +| Analyzers | CMTK1013–1021 refuse a declaration that cannot be generated, CMTK0012 an argument by position | None | INTELLENUM001–030 | TTRESG rules, with refactorings | +| A wire name apart from the identifier | No: the name is the field's | Yes, the constructor's name | Yes, `new("STD", 1)` | Yes, the key | +| Targets | `net10.0` | `netstandard2.0`, `net6.0` to `net8.0` | `netstandard2.0` | `net8.0`, `net9.0` | +| Generated by | Metalama, in the declaring project | Nothing: a run-time base class | A Roslyn source generator | Roslyn source generators | +| License | MIT | MIT | Apache-2.0 | BSD-3-Clause | diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 3f71cae..ae2d20f 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -16,7 +16,7 @@ those projects. No Metalama license key is needed, there or here: the generators Open Source edition. How this compares with Vogen, Thinktecture.Runtime.Extensions and StronglyTypedId, and when one of -them is the better choice: [How it compares](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit#how-it-compares). +them is the better choice: [How it compares](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/comparison.md). ## Built with Metalama From cea871659eff031ac5a8d3b39124cfccc4583ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:30:24 +0200 Subject: [PATCH 043/147] docs: design/plan.md becomes a map; the decisions are records, the areas notes, the open items a roadmap, the phases history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One file of 1,887 lines held four things that change at different rates: a package map, a phase log of 876 lines duplicating git and the changelog, 51 numbered decisions, and a design note per area. Every session read all of it to find the fifty lines that mattered, a decision had no address of its own, and the log grew with every step. Now design/plan.md is the map, design/roadmap.md holds what is open and nothing else, design/adr/NNNN-slug.md is one dated record per decision with its status and the evidence, indexed in design/adr/README.md, design/.md is the note per area, and design/history.md keeps the phase log as it was, closed, for the "why" the changelog does not hold. Every "§N" and "decision N" is a link, the spike READMEs, the code comments, the workflows and the package READMEs point at the new files, and AGENTS.md's rule reads the map, the roadmap, the records and the note for the area touched. The link conventions now cover design/, and DesignRecordTests holds every record to its number, title, date and status and to a row in the index. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/dotnet.yml | 4 +- .github/workflows/release.yml | 2 +- AGENTS.md | 13 +- CHANGELOG.md | 4 +- CONTRIBUTING.md | 4 +- Directory.Packages.props | 2 +- README.md | 6 +- design/adr/0001-license-mit.md | 8 + design/adr/0002-name-codometis-typekit.md | 9 + design/adr/0003-analyzer-ids-cmtk.md | 9 + .../0004-metalama-2026-1-x-latest-stable.md | 15 + design/adr/0005-option-result.md | 9 + design/adr/0006-result-shape.md | 9 + design/adr/0007-analyzer-own-package.md | 8 + .../adr/0008-uninitialized-result-throws.md | 8 + ...tive-irelationaltypemappingsourceplugin.md | 9 + ...-stored-json-storedjsonconverterfactory.md | 6 + design/adr/0011-openapi-schema-transformer.md | 8 + .../adr/0012-asp0020-documented-suppressed.md | 9 + .../0013-first-release-s-rule-set-cmtk0001.md | 12 + design/adr/0014-context7-json.md | 6 + design/adr/0015-option-result-wire-types.md | 10 + ...016-native-aot-wherever-platform-allows.md | 11 + ...eratedvalueobjectattribute-tvalueobject.md | 13 + design/adr/0018-name-per-concept.md | 23 + design/adr/0019-concise-implicit.md | 24 + design/adr/0020-net10-0-only.md | 10 + design/adr/0021-first-release-1-0-0.md | 11 + ...-extensions-live-framework-s-namespaces.md | 12 + ...value-object-key-over-integer-generated.md | 9 + ...ested-value-objects-named-after-nesting.md | 8 + .../0025-analyzer-s-roslyn-floor-stays-5.md | 12 + .../0026-value-object-s-json-wrapped-type.md | 9 + design/adr/0027-no-refusal-quotes-input.md | 10 + ...-seams-declared-everything-else-refused.md | 13 + .../0029-only-result-terror-converts-bool.md | 11 + ...0030-asynchronous-callbacks-return-task.md | 11 + ...031-no-extension-object-unconstrained-t.md | 10 + ...-value-object-holds-wrapped-value-alone.md | 12 + ...analyzers-run-code-metalama-transformed.md | 10 + ...ors-fixed-within-major-version-warnings.md | 11 + ...lite-s-autoincrement-application-s-line.md | 12 + ...llable-value-object-admits-null-openapi.md | 10 + .../0037-refusal-s-message-landing-page.md | 11 + ...s-response-explicit-tohttpresult-faults.md | 18 + .../0039-value-object-s-fault-does-become.md | 14 + ...s-typekit-testing-holds-assertions-hand.md | 17 + ...-value-object-declares-tostring-serilog.md | 20 + design/adr/0042-2-0-0-next-release-bundles.md | 14 + ...ument-constraints-constructor-guarantee.md | 22 + design/adr/0044-2-0-0-net10-0-metalama.md | 17 + ...-topic-pages-docs-compiled-like-readmes.md | 22 + .../0046-timeout-caught-result-catch-error.md | 16 + .../adr/0047-firstornone-query-runs-query.md | 15 + ...d-break-suppressed-alone-baseline-stays.md | 14 + .../0049-fault-responses-client-can-act.md | 28 + ...warnings-cmtk0003-alone-becomes-error-2.md | 25 + ...51-closed-set-parses-ignoring-case-only.md | 16 + design/adr/README.md | 60 + design/analyzers.md | 75 + design/aspects.md | 218 ++ design/discovery.md | 18 + design/ef-core.md | 77 + design/history.md | 920 ++++++++ design/native-aot.md | 38 + design/openapi.md | 52 + design/option-result.md | 123 ++ design/packages.md | 34 + design/plan.md | 1941 +---------------- design/roadmap.md | 42 + design/test-strategy.md | 31 + spikes/ClosedSets/README.md | 2 +- spikes/FabricSpike/README.md | 4 +- spikes/FaultResponses/README.md | 10 +- spikes/MergePatch/README.md | 2 +- spikes/RangeValueObjects/README.md | 8 +- spikes/ResultCatch/README.md | 2 +- spikes/ResultHttp/README.md | 8 +- spikes/SensitiveValues/README.md | 2 +- spikes/TypeArgumentConstraints/README.md | 6 +- .../KnownGoodFromCallerAnalyzer.cs | 2 +- src/CodoMetis.TypeKit/README.md | 4 +- .../AotCompatibilityTests.cs | 2 +- .../DesignRecordTests.cs | 44 + .../DocumentationLinkTests.cs | 12 +- .../ReadmeSampleTests.cs | 2 +- 86 files changed, 2469 insertions(+), 1961 deletions(-) create mode 100644 design/adr/0001-license-mit.md create mode 100644 design/adr/0002-name-codometis-typekit.md create mode 100644 design/adr/0003-analyzer-ids-cmtk.md create mode 100644 design/adr/0004-metalama-2026-1-x-latest-stable.md create mode 100644 design/adr/0005-option-result.md create mode 100644 design/adr/0006-result-shape.md create mode 100644 design/adr/0007-analyzer-own-package.md create mode 100644 design/adr/0008-uninitialized-result-throws.md create mode 100644 design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md create mode 100644 design/adr/0010-stored-json-storedjsonconverterfactory.md create mode 100644 design/adr/0011-openapi-schema-transformer.md create mode 100644 design/adr/0012-asp0020-documented-suppressed.md create mode 100644 design/adr/0013-first-release-s-rule-set-cmtk0001.md create mode 100644 design/adr/0014-context7-json.md create mode 100644 design/adr/0015-option-result-wire-types.md create mode 100644 design/adr/0016-native-aot-wherever-platform-allows.md create mode 100644 design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md create mode 100644 design/adr/0018-name-per-concept.md create mode 100644 design/adr/0019-concise-implicit.md create mode 100644 design/adr/0020-net10-0-only.md create mode 100644 design/adr/0021-first-release-1-0-0.md create mode 100644 design/adr/0022-registration-extensions-live-framework-s-namespaces.md create mode 100644 design/adr/0023-value-object-key-over-integer-generated.md create mode 100644 design/adr/0024-nested-value-objects-named-after-nesting.md create mode 100644 design/adr/0025-analyzer-s-roslyn-floor-stays-5.md create mode 100644 design/adr/0026-value-object-s-json-wrapped-type.md create mode 100644 design/adr/0027-no-refusal-quotes-input.md create mode 100644 design/adr/0028-seams-declared-everything-else-refused.md create mode 100644 design/adr/0029-only-result-terror-converts-bool.md create mode 100644 design/adr/0030-asynchronous-callbacks-return-task.md create mode 100644 design/adr/0031-no-extension-object-unconstrained-t.md create mode 100644 design/adr/0032-value-object-holds-wrapped-value-alone.md create mode 100644 design/adr/0033-analyzers-run-code-metalama-transformed.md create mode 100644 design/adr/0034-errors-fixed-within-major-version-warnings.md create mode 100644 design/adr/0035-sqlite-s-autoincrement-application-s-line.md create mode 100644 design/adr/0036-nullable-value-object-admits-null-openapi.md create mode 100644 design/adr/0037-refusal-s-message-landing-page.md create mode 100644 design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md create mode 100644 design/adr/0039-value-object-s-fault-does-become.md create mode 100644 design/adr/0040-codometis-typekit-testing-holds-assertions-hand.md create mode 100644 design/adr/0041-sensitive-value-object-declares-tostring-serilog.md create mode 100644 design/adr/0042-2-0-0-next-release-bundles.md create mode 100644 design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md create mode 100644 design/adr/0044-2-0-0-net10-0-metalama.md create mode 100644 design/adr/0045-topic-pages-docs-compiled-like-readmes.md create mode 100644 design/adr/0046-timeout-caught-result-catch-error.md create mode 100644 design/adr/0047-firstornone-query-runs-query.md create mode 100644 design/adr/0048-intended-break-suppressed-alone-baseline-stays.md create mode 100644 design/adr/0049-fault-responses-client-can-act.md create mode 100644 design/adr/0050-warnings-cmtk0003-alone-becomes-error-2.md create mode 100644 design/adr/0051-closed-set-parses-ignoring-case-only.md create mode 100644 design/adr/README.md create mode 100644 design/analyzers.md create mode 100644 design/aspects.md create mode 100644 design/discovery.md create mode 100644 design/ef-core.md create mode 100644 design/history.md create mode 100644 design/native-aot.md create mode 100644 design/openapi.md create mode 100644 design/option-result.md create mode 100644 design/packages.md create mode 100644 design/roadmap.md create mode 100644 design/test-strategy.md create mode 100644 test/CodoMetis.TypeKit.Conventions.Tests/DesignRecordTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a9a06a8..322b22f 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -94,7 +94,7 @@ jobs: run: dotnet pack CodoMetis.TypeKit.slnx -c Release --no-build -o dist # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (design/plan.md §11). + # and zlib (design/native-aot.md). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev @@ -143,7 +143,7 @@ jobs: global-json-file: global.json # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (design/plan.md §11). + # and zlib (design/native-aot.md). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03eee2d..ffe3c3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -136,7 +136,7 @@ jobs: run: dotnet pack CodoMetis.TypeKit.slnx -c Release --no-build -o dist # The smoke test publishes a consumer with Native AOT, which links with the platform's clang - # and zlib (design/plan.md §11). + # and zlib (design/native-aot.md). - name: Native AOT prerequisites run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev diff --git a/AGENTS.md b/AGENTS.md index 8a431a7..1cd3547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ that are written as one line and generated at compile time by Metalama. - **Satellites** map value objects to EF Core columns, where `.Value` works in LINQ as it does in memory, and to OpenAPI schemas. -The packages are being built phase by phase: **read [design/plan.md](design/plan.md) before changing -anything.** It holds the package map, the phase order and the decisions. +**Read [design/plan.md](design/plan.md) before changing anything.** It is the map of `design/`: the +[roadmap](design/roadmap.md), the [decision records](design/adr/README.md), one dated file per +decision with its evidence, and a design note per area. This file is the canonical agent guide. There is no CLAUDE.md, on purpose. @@ -47,7 +48,8 @@ This file is the canonical agent guide. There is no CLAUDE.md, on purpose. - `spikes/` holds experiments that decided something. Each one has a README with the question, the answer and the evidence. Spikes are not in the main solution and are never packed. - `docs/` holds the topic pages a consumer reads, one per topic, each linked from the root README's - "Documentation" table; `design/` holds the plan and the design notes, which no test compiles. + "Documentation" table; `design/` holds the roadmap, the decision records and the design notes, + which no test compiles but whose links the conventions check. Never hard-code a positional path (`../../`) to reach the repo root. Walk up to the `CodoMetis.TypeKit.slnx` marker instead. @@ -93,7 +95,7 @@ Platform (xunit.v3 4.x), so `dotnet test` takes `--solution`/`--project`, and TR `JsonSerializer` call that takes only options, in the packages or in woven code: a typed call (`GeneratedValueObjectAttribute.Accept`, `GeneratedJson.TypeInfo`) or an interface instead. An `[UnconditionalSuppressMessage]` needs a measured reason, and the smoke test's `aot` consumer is - the measurement (design/plan.md §11). + the measurement ([design/native-aot.md](design/native-aot.md)). - **Aspects stay internal.** Their public surface is what they generate, and nothing else. - **A value object's JSON is its wrapped type's, byte for byte**, under whatever options the application uses, values and dictionary keys alike (`JsonParityTests`). Never write or read the @@ -120,7 +122,8 @@ Platform (xunit.v3 4.x), so `dotnet test` takes `--solution`/`--project`, and TR characteristic failures. Prefer a diagnostic or an exception to a plausible result. ## Workflow -1. Read `design/plan.md` and the relevant spike README before starting. +1. Read `design/plan.md`, the roadmap, the decision records and the design note for the area you + touch, and the relevant spike README, before starting. A decision is changed by a new record. 2. Run `dotnet test` after each change. 3. **Prove every fix by reverting it.** See `.claude/skills/verify-the-guard`. The same applies to a new analyzer test or a convention test: seed the defect it claims to catch. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1f193..beac65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,7 +148,9 @@ this release adds, so nothing that built against 1.5.1 meets them. value-object, result and smart-enum libraries, moved out of the README with its dated tables. Every C# block in them is compiled and its stated outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete public surface, checked against the types. The package READMEs remain the reference for each - package. The plan moved from `docs/plan.md` to `design/plan.md`. + package. The plan moved from `docs/plan.md` to `design/plan.md`, and on 2026-10-13 was split: a decision + record per decision under `design/adr/`, a design note per area, a roadmap, the phase log as + history, and `design/plan.md` as the map. - **Fault responses a client can act on.** In `CodoMetis.TypeKit`: `FaultKind`, `IFault` and `IFaultSet`, a closed set whose members carry a kind and an optional description, and `Fault`, a fault with a sentence about one occurrence (`Fault.Of(reason, detail)`). In diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdcac96..e700d9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,8 @@ one consumer with Native AOT, which needs the platform's native toolchain: clang tools on macOS; `clang` and `zlib1g-dev` on Debian and Ubuntu). Shipping projects live under `src/` and test projects under `test/`; the topic pages a consumer reads are in `docs/`, each compiled like the READMEs, and the plan is in `design/`. -[AGENTS.md](AGENTS.md) is the architecture guide and [design/plan.md](design/plan.md) the current -roadmap. Read both before changing `Option`/`Result`, an aspect, the fabric, the analyzer or a +[AGENTS.md](AGENTS.md) is the architecture guide and [design/plan.md](design/plan.md) the map of the +design notes, the decision records and the roadmap. Read both before changing `Option`/`Result`, an aspect, the fabric, the analyzer or a satellite. ## The quality bar diff --git a/Directory.Packages.props b/Directory.Packages.props index dc9212f..6c2b6a5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ one is a release decision, not a dependency update. --> - + diff --git a/README.md b/README.md index eb6f010..b029607 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,9 @@ The packages share one version and follow Semantic Versioning: the public API, the generated members of a value object, and the analyzer ids and severities are the contract. A new analyzer rule arrives as a warning or a suggestion in a minor version, and becomes an error only in a major one. -[CHANGELOG.md](CHANGELOG.md) lists what each version changed. The plan, the decisions and their -evidence are in [design/plan.md](design/plan.md), and the measurements that decided the design are in -[spikes/](spikes/). +[CHANGELOG.md](CHANGELOG.md) lists what each version changed. The design notes, the decision records +and the roadmap are under [design/](design/plan.md), and the measurements that decided the design are +in [spikes/](spikes/). ## Building diff --git a/design/adr/0001-license-mit.md b/design/adr/0001-license-mit.md new file mode 100644 index 0000000..99cd1e0 --- /dev/null +++ b/design/adr/0001-license-mit.md @@ -0,0 +1,8 @@ +# 0001. License: MIT + +Date: 2026-09-26 +Status: accepted + +Context: 2026-09-26. + + diff --git a/design/adr/0002-name-codometis-typekit.md b/design/adr/0002-name-codometis-typekit.md new file mode 100644 index 0000000..34a7403 --- /dev/null +++ b/design/adr/0002-name-codometis-typekit.md @@ -0,0 +1,9 @@ +# 0002. Name: `CodoMetis.TypeKit` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +, replacing `CodoMetis.ValueObjects`, which was too + narrow. Package map in §1. diff --git a/design/adr/0003-analyzer-ids-cmtk.md b/design/adr/0003-analyzer-ids-cmtk.md new file mode 100644 index 0000000..2c1ef5c --- /dev/null +++ b/design/adr/0003-analyzer-ids-cmtk.md @@ -0,0 +1,9 @@ +# 0003. Analyzer ids: `CMTK` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +Ids are public contract: a rule keeps its id even if it + later moves to another package. diff --git a/design/adr/0004-metalama-2026-1-x-latest-stable.md b/design/adr/0004-metalama-2026-1-x-latest-stable.md new file mode 100644 index 0000000..470b521 --- /dev/null +++ b/design/adr/0004-metalama-2026-1-x-latest-stable.md @@ -0,0 +1,15 @@ +# 0004. Metalama 2026.1.x (latest stable) + +Date: 2026-09-26 +Status: accepted + +Context: 2026-09-26. + +, not the 2027.0 preview. + - Aspect state keeps `IDurableRef`, which exists in 2026.1. + - `[Durable]` on the `_value` template placeholder is omitted, because it is 2027.0-only, and + is added back on upgrade. + - A 2026.1-built package works in a 2027.0 consumer (fabric spike finding 11). + - Once 2027.0 is stable, upgrading is a planned task: MIT maintenance covers only the latest + `YYYY.N`. + - **Metalama is the generator technology for this project**, not a candidate for replacement. diff --git a/design/adr/0005-option-result.md b/design/adr/0005-option-result.md new file mode 100644 index 0000000..244fb36 --- /dev/null +++ b/design/adr/0005-option-result.md @@ -0,0 +1,9 @@ +# 0005. Option/Result + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +: pure `Option`/`Result`, not a LanguageExt-style framework. + They ship in the base package. No `Try`/`Unit` ([§9](../option-result.md)). diff --git a/design/adr/0006-result-shape.md b/design/adr/0006-result-shape.md new file mode 100644 index 0000000..533ae03 --- /dev/null +++ b/design/adr/0006-result-shape.md @@ -0,0 +1,9 @@ +# 0006. `Result` shape + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +: `Result` (value first), `readonly record struct`, + private fields, non-positional ([§9](../option-result.md)). diff --git a/design/adr/0007-analyzer-own-package.md b/design/adr/0007-analyzer-own-package.md new file mode 100644 index 0000000..c7ba7ec --- /dev/null +++ b/design/adr/0007-analyzer-own-package.md @@ -0,0 +1,8 @@ +# 0007. Analyzer as its own package + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +, reaching consumers through the base package ([§10](../analyzers.md)). diff --git a/design/adr/0008-uninitialized-result-throws.md b/design/adr/0008-uninitialized-result-throws.md new file mode 100644 index 0000000..65b4ec6 --- /dev/null +++ b/design/adr/0008-uninitialized-result-throws.md @@ -0,0 +1,8 @@ +# 0008. An uninitialized `Result` throws + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +instead of reporting `default(TError)` ([§9](../option-result.md)). diff --git a/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md b/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md new file mode 100644 index 0000000..0e60b4b --- /dev/null +++ b/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md @@ -0,0 +1,9 @@ +# 0009. EF mapping through an additive `IRelationalTypeMappingSourcePlugin` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27. + +, not a + replaced `IValueConverterSelector` or a pre-convention scan ([§4](../ef-core.md), spikes/EfMapping). diff --git a/design/adr/0010-stored-json-storedjsonconverterfactory.md b/design/adr/0010-stored-json-storedjsonconverterfactory.md new file mode 100644 index 0000000..9aaffc7 --- /dev/null +++ b/design/adr/0010-stored-json-storedjsonconverterfactory.md @@ -0,0 +1,6 @@ +# 0010. Stored JSON through `StoredJsonConverterFactory` + +Date: 2026-09-27 +Status: accepted + +in the base package (2026-09-27, [§4](../ef-core.md)). diff --git a/design/adr/0011-openapi-schema-transformer.md b/design/adr/0011-openapi-schema-transformer.md new file mode 100644 index 0000000..186324a --- /dev/null +++ b/design/adr/0011-openapi-schema-transformer.md @@ -0,0 +1,8 @@ +# 0011. OpenAPI through one schema transformer + +Date: 2026-09-27 +Status: accepted + +that fills a value object's schema in from the +wrapped type's, as ASP.NET publishes it (2026-09-27, [§7](../openapi.md), spikes/OpenApiSchemas), not a document +transformer and not a format table. diff --git a/design/adr/0012-asp0020-documented-suppressed.md b/design/adr/0012-asp0020-documented-suppressed.md new file mode 100644 index 0000000..5b3c095 --- /dev/null +++ b/design/adr/0012-asp0020-documented-suppressed.md @@ -0,0 +1,9 @@ +# 0012. ASP0020 is documented, not suppressed + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, §7. + +: the package cannot suppress it, and +a blanket suppression would hide the check for every other type. diff --git a/design/adr/0013-first-release-s-rule-set-cmtk0001.md b/design/adr/0013-first-release-s-rule-set-cmtk0001.md new file mode 100644 index 0000000..5d82894 --- /dev/null +++ b/design/adr/0013-first-release-s-rule-set-cmtk0001.md @@ -0,0 +1,12 @@ +# 0013. The first release's rule set is CMTK0001–CMTK0009 + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, [§10](../analyzers.md); CMTK0009 added the same +day, at Warning, for the calls that hand out a default. + +CMTK0004 ships at Error +in 1.0.0, the one release where a new Error rule needs no major version; CMTK0003, CMTK0005 and +CMTK0006 at Warning, CMTK0006 once its fix was measured; CMTK0007 at Info, reporting only a +value that comes straight from a parameter. diff --git a/design/adr/0014-context7-json.md b/design/adr/0014-context7-json.md new file mode 100644 index 0000000..3562b94 --- /dev/null +++ b/design/adr/0014-context7-json.md @@ -0,0 +1,6 @@ +# 0014. context7.json. + +Date: 2026-10-13 +Status: open + +The sibling repos register one. Add it once the repo is public. diff --git a/design/adr/0015-option-result-wire-types.md b/design/adr/0015-option-result-wire-types.md new file mode 100644 index 0000000..12a6788 --- /dev/null +++ b/design/adr/0015-option-result-wire-types.md @@ -0,0 +1,10 @@ +# 0015. `Option`/`Result` are not wire types + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, §9. + +: a `[JsonConverter]` on every exported +struct of the base package throws `NotSupportedException` in both directions. Not a lossless +converter, and not an analyzer. diff --git a/design/adr/0016-native-aot-wherever-platform-allows.md b/design/adr/0016-native-aot-wherever-platform-allows.md new file mode 100644 index 0000000..c569434 --- /dev/null +++ b/design/adr/0016-native-aot-wherever-platform-allows.md @@ -0,0 +1,11 @@ +# 0016. Native AOT wherever the platform allows it + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, §11. + +: the run-time packages build with +`IsAotCompatible`, the woven code is published with Native AOT in the smoke test, and a path that +needs reflection or dynamic code is rewritten rather than annotated or suppressed. EF Core itself +stays experimental under Native AOT; the satellite adds nothing to what EF requires. diff --git a/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md b/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md new file mode 100644 index 0000000..cde3c0e --- /dev/null +++ b/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md @@ -0,0 +1,13 @@ +# 0017. Run-time discovery reads `GeneratedValueObjectAttribute` + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, [§3](../discovery.md), §11. + +, +which the generators put on every value object, not `GetInterfaces()`. Measured under Native AOT: +the interface was gone from every value object reached through a property, and the OpenAPI +satellite described each as `{}`. The attribute's constraints are the interfaces, so what a value +object is has not changed; its `Accept` hands the two types to a visitor as type arguments, which +is how the EF satellite makes a converter without `MakeGenericType`. diff --git a/design/adr/0018-name-per-concept.md b/design/adr/0018-name-per-concept.md new file mode 100644 index 0000000..630fbda --- /dev/null +++ b/design/adr/0018-name-per-concept.md @@ -0,0 +1,23 @@ +# 0018. One name per concept + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, before the first release, when renaming still costs nothing. + +- What a value object holds is *wrapped*: `GeneratedValueObjectAttribute.WrappedType` and + `TranslatedAsWrappedValueAttribute`, not "underlying", and not `ValueType`, which in .NET means + a struct and here returned `typeof(string)` for a string-backed value object. +- A value object without rules is *plain*: `IPlainValueObject`, which has `From`. It was + `IValueWrapper`, which named what every value object does. +- The value object's own type parameter is `TSelf` on the self-referencing contracts, as in + `IParsable`, and `TValueObject` where a helper or attribute names one. Never `TValue`, + which `KnownGood` used for the value object while "value" means the wrapped value everywhere + else. +- Delegate parameters: `onSome`/`onNone`, `onSuccess`/`onError`, `selector`, `predicate`, + `action`, as LINQ names them. They were `fnSome`, `map`, `fn`, `check` and `defaultProvider`. +- Namespaces follow who uses a type ([§1](../packages.md)). The helpers only generated code calls moved to + `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense, and `Accepted` and `KnownGood` + became one `GeneratedFactories` whose methods name the exception each entry point throws. +- Internally: `ValueObjectAspect` (was the implementation aspect), `ValueObjectContractAspect`, + `ValueObjectCompanionAspect`, `ValueObjectKind.Plain`. diff --git a/design/adr/0019-concise-implicit.md b/design/adr/0019-concise-implicit.md new file mode 100644 index 0000000..d16c5ef --- /dev/null +++ b/design/adr/0019-concise-implicit.md @@ -0,0 +1,24 @@ +# 0019. Concise, not implicit + +Date: 2026-09-27 +Status: accepted + +Context: 2026-09-27, §9. + +Markers where a target type exists, explicit type +arguments where none does. +- One word for success: `Result.Success(...)` beside `Result<…>.Success(...)`, the `Success` + markers, `ResultState.Success` and `onSuccess`, as `Option.Some` sits beside `Option`. + `Result.Ok` was the one place that said it differently. +- `Option.None()` returns a marker that converts to any `Option`; `Option.None()` stays for + `var`. The analyzer forbids `default(Option)`, so every None needed its type argument. +- `MapError` on both shapes, for crossing layers, which needed + `Match>(x => x, e => Result.Error(…))`; and `Bind(Func>)`, a + command after a query, which replaces the collapsing `Match(_ => Result.Ok(), e => e)`, a + conversion wearing `Match`'s name. +- `Option.ToResult(error)` keeps the value (`Result`); it returned `Result`, + dropping it. `Coalesce` is `Or`, beside `OrDefault()` and `OrNull()`. +- Not added: an implicit `T` → `Option`, since a null would throw exactly where a reader + expects `None`; a bare error → `Result` (the ambiguity above); a shipped + `using static`. A lambda that returns a bare value and `Result.Error(...)` still needs its type + argument, because C# infers a lambda's return type from its body alone; the README says so. diff --git a/design/adr/0020-net10-0-only.md b/design/adr/0020-net10-0-only.md new file mode 100644 index 0000000..fba17f6 --- /dev/null +++ b/design/adr/0020-net10-0-only.md @@ -0,0 +1,10 @@ +# 0020. net10.0 only + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +The satellites cannot go lower (EF Core 10 and +Microsoft.AspNetCore.OpenApi 10 require it), the contracts use static abstract interface members, +which rules out netstandard2.0, and .NET 8's support ends on 2026-11-10. diff --git a/design/adr/0021-first-release-1-0-0.md b/design/adr/0021-first-release-1-0-0.md new file mode 100644 index 0000000..2774268 --- /dev/null +++ b/design/adr/0021-first-release-1-0-0.md @@ -0,0 +1,11 @@ +# 0021. The first release is 1.0.0 + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +, not 0.1.0. What the reasoning above says of "the +first release" (CMTK0004 at Error, refusals that can be lifted later) holds for 1.0.0 unchanged. +From then on the public API, the generated surface and the rule ids and severities follow +Semantic Versioning, and package validation compares every pack with 1.0.0. diff --git a/design/adr/0022-registration-extensions-live-framework-s-namespaces.md b/design/adr/0022-registration-extensions-live-framework-s-namespaces.md new file mode 100644 index 0000000..d22ee12 --- /dev/null +++ b/design/adr/0022-registration-extensions-live-framework-s-namespaces.md @@ -0,0 +1,12 @@ +# 0022. Registration extensions live in the framework's namespaces + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +: `UseTypeKit()` in +`Microsoft.EntityFrameworkCore`, `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()` in +`Microsoft.Extensions.DependencyInjection`, as EF's providers and `AddOpenApi` do, so the one line a +host adds needs no `using` (the docs audit found every setup sample missing one). Types stay in +`CodoMetis.TypeKit.*`. Moving them after 1.0.0 would break every host. diff --git a/design/adr/0023-value-object-key-over-integer-generated.md b/design/adr/0023-value-object-key-over-integer-generated.md new file mode 100644 index 0000000..bb6d115 --- /dev/null +++ b/design/adr/0023-value-object-key-over-integer-generated.md @@ -0,0 +1,9 @@ +# 0023. A value-object key over an integer is generated on add; one over a `Guid` is assigned + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §4. + +Parity with the wrapped key where only the database can make one, and the +application's `OrderId.New()` where it can. diff --git a/design/adr/0024-nested-value-objects-named-after-nesting.md b/design/adr/0024-nested-value-objects-named-after-nesting.md new file mode 100644 index 0000000..358adbf --- /dev/null +++ b/design/adr/0024-nested-value-objects-named-after-nesting.md @@ -0,0 +1,8 @@ +# 0024. Nested value objects are named after their nesting chain in the OpenAPI document + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §7. + +, as their companion classes are. diff --git a/design/adr/0025-analyzer-s-roslyn-floor-stays-5.md b/design/adr/0025-analyzer-s-roslyn-floor-stays-5.md new file mode 100644 index 0000000..937bbaa --- /dev/null +++ b/design/adr/0025-analyzer-s-roslyn-floor-stays-5.md @@ -0,0 +1,12 @@ +# 0025. The analyzer's Roslyn floor stays at 5.0.0 + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +, the compiler of the 10.0.1xx SDK band, +which is serviced for all of .NET 10 and is what Linux distributions build. Measured: built on +5.9.0 (SDK 10.0.4xx) and run by the 5.0.0 compiler, the analyzer is not loaded (CS9057, a warning) +and `default(Option)` compiles. `AnalyzerPackagingTests` reads the packed analyzer's +references. The build-time `Microsoft.CodeAnalysis.Analyzers` follows the SDK (5.9.0). diff --git a/design/adr/0026-value-object-s-json-wrapped-type.md b/design/adr/0026-value-object-s-json-wrapped-type.md new file mode 100644 index 0000000..fd918c8 --- /dev/null +++ b/design/adr/0026-value-object-s-json-wrapped-type.md @@ -0,0 +1,9 @@ +# 0026. A value object's JSON is its wrapped type's, byte for byte + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §5. + +, under whatever +options the application uses: swapping a primitive for a value object changes no wire format. diff --git a/design/adr/0027-no-refusal-quotes-input.md b/design/adr/0027-no-refusal-quotes-input.md new file mode 100644 index 0000000..764992d --- /dev/null +++ b/design/adr/0027-no-refusal-quotes-input.md @@ -0,0 +1,10 @@ +# 0027. No refusal quotes the input + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §5. + +, not even one the wrapped type raises: every +generated `Parse`, type converter and JSON read replaces the wrapped type's exception with one that +names the value object and the wrapped type. diff --git a/design/adr/0028-seams-declared-everything-else-refused.md b/design/adr/0028-seams-declared-everything-else-refused.md new file mode 100644 index 0000000..362998d --- /dev/null +++ b/design/adr/0028-seams-declared-everything-else-refused.md @@ -0,0 +1,13 @@ +# 0028. Seams are declared, everything else refused + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §5. + +: `TryFrom`, `FromKnownGood`, +`Revalidate`, `CompareTo(TSelf)` and `ToString()` may be written by hand and are kept; any other +member or attribute the generators introduce is CMTK1011, never kept silently. Since phase 11 so +is an explicit implementation of an interface they implement, a hand-written equality, and +`PrintMembers` without `ToString()`, declared or inherited from a base record, whose `sealed` +`ToString()` is the seam; state besides the wrapped value is CMTK1012. diff --git a/design/adr/0029-only-result-terror-converts-bool.md b/design/adr/0029-only-result-terror-converts-bool.md new file mode 100644 index 0000000..e44882d --- /dev/null +++ b/design/adr/0029-only-result-terror-converts-bool.md @@ -0,0 +1,11 @@ +# 0029. Only `Result` converts to `bool` + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §9. + +On a valued result the conversion +said whether the operation succeeded where a reader expects the value: `if (await +IsEmailTakenAsync(email))` over `Result` took the branch for `Success(false)`. The +same reason keeps a bare error from converting into one. Match it, or compare `State`. diff --git a/design/adr/0030-asynchronous-callbacks-return-task.md b/design/adr/0030-asynchronous-callbacks-return-task.md new file mode 100644 index 0000000..3908a28 --- /dev/null +++ b/design/adr/0030-asynchronous-callbacks-return-task.md @@ -0,0 +1,11 @@ +# 0030. Asynchronous callbacks return `Task` + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §9. + +`TapErrorAsync(Func)` sits +beside `Action`, as `TapAsync` already did. It ships in 1.0 because adding it later +rebinds existing calls. No `ValueTask` overloads: beside the `Task` ones every `async` lambda +becomes ambiguous (CS0121, measured). diff --git a/design/adr/0031-no-extension-object-unconstrained-t.md b/design/adr/0031-no-extension-object-unconstrained-t.md new file mode 100644 index 0000000..f09d2ad --- /dev/null +++ b/design/adr/0031-no-extension-object-unconstrained-t.md @@ -0,0 +1,10 @@ +# 0031. No extension on `object` or on an unconstrained `T` + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §9. + +: it is offered on every +expression of every file that imports the namespace, and collides with anyone else's. +`No_extension_member_extends_every_type` holds it. diff --git a/design/adr/0032-value-object-holds-wrapped-value-alone.md b/design/adr/0032-value-object-holds-wrapped-value-alone.md new file mode 100644 index 0000000..9d9d939 --- /dev/null +++ b/design/adr/0032-value-object-holds-wrapped-value-alone.md @@ -0,0 +1,12 @@ +# 0032. A value object holds its wrapped value alone, and its equality is that value's + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, +§5. + +Everything generated (JSON, parsing, the type converter, the EF column, comparison) carries +the wrapped value, so other state (CMTK1012), a hand-written equality or an explicit +implementation beside a generated member (CMTK1011, CMTK1008) would make two views of one value +disagree. Normalise in `Create` instead. diff --git a/design/adr/0033-analyzers-run-code-metalama-transformed.md b/design/adr/0033-analyzers-run-code-metalama-transformed.md new file mode 100644 index 0000000..6738066 --- /dev/null +++ b/design/adr/0033-analyzers-run-code-metalama-transformed.md @@ -0,0 +1,10 @@ +# 0033. The analyzers run on the code Metalama transformed + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §10. + +, through a +`MetalamaTransformedCodeAnalyzer` item in the analyzer package's `buildTransitive` props. The +namespace covers every rule, a future one included. diff --git a/design/adr/0034-errors-fixed-within-major-version-warnings.md b/design/adr/0034-errors-fixed-within-major-version-warnings.md new file mode 100644 index 0000000..5f43de3 --- /dev/null +++ b/design/adr/0034-errors-fixed-within-major-version-warnings.md @@ -0,0 +1,11 @@ +# 0034. Errors are fixed within a major version; Warnings may learn + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §10. + +A new Error, or +an Error that reports more, waits for a major version. A Warning or Info may report more forms in +a minor version. The CMTK0003, CMTK0008 and CMTK0009 gaps the second review found were closed +before 1.0.0 was tagged all the same, so the rules ship as complete as the review could make them. diff --git a/design/adr/0035-sqlite-s-autoincrement-application-s-line.md b/design/adr/0035-sqlite-s-autoincrement-application-s-line.md new file mode 100644 index 0000000..9d29c83 --- /dev/null +++ b/design/adr/0035-sqlite-s-autoincrement-application-s-line.md @@ -0,0 +1,12 @@ +# 0035. SQLite's autoincrement is the application's line, not the convention's + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, §4. + +EF's +SQLite provider decides by the CLR type, and the only other way in is an annotation typed by the +SQLite assembly: setting it means naming the provider or writing a foreign annotation into every +other provider's model. The README documents `UseAutoincrement()`; `MigrationSnapshotTests` fails +once EF fixes the check, which is when the note can go. diff --git a/design/adr/0036-nullable-value-object-admits-null-openapi.md b/design/adr/0036-nullable-value-object-admits-null-openapi.md new file mode 100644 index 0000000..b4a821e --- /dev/null +++ b/design/adr/0036-nullable-value-object-admits-null-openapi.md @@ -0,0 +1,10 @@ +# 0036. A nullable value object admits null in the OpenAPI document wherever it appears + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28, +§7. + +: as a property, as a container's element (`oneOf: [null, component]`, ASP.NET's own form), +and inlined (null added to `type`, or `oneOf` around an inlined enum). diff --git a/design/adr/0037-refusal-s-message-landing-page.md b/design/adr/0037-refusal-s-message-landing-page.md new file mode 100644 index 0000000..22e60bb --- /dev/null +++ b/design/adr/0037-refusal-s-message-landing-page.md @@ -0,0 +1,11 @@ +# 0037. A refusal's message is its landing page + +Date: 2026-09-28 +Status: accepted + +Context: 2026-09-28. + +: an exception has no help link, so every +deliberate run-time refusal says what to do instead and ends with a link to its subsection of the +base README's "Why does this throw?", and every generator build error with one to the Build errors +table. The links are held to the README's headings by tests. Nothing of the input is added. diff --git a/design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md b/design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md new file mode 100644 index 0000000..a4a7826 --- /dev/null +++ b/design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md @@ -0,0 +1,18 @@ +# 0038. A `Result` becomes a response through an explicit `ToHttpResult()`, its faults mapped in the host + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, phase 12, spikes/ResultHttp. + +The return type is ASP.NET's +`Results, FaultProblem>`, so ASP.NET describes the success and the Request Delegate +Generator needs nothing of ours; a handler returning the `Result` itself would document a `Result`. +The fault type is an **enum**: its members are what a mapping can be checked against when it is +registered, and what the document lists. A fault of another shape is `MapError`ed to one at the +endpoint. The extensions live beside the framework's (`ToHttpResult` in `Microsoft.AspNetCore.Http`, +`AddFaultResponses` in `Microsoft.Extensions.DependencyInjection`, [decision 22](0022-registration-extensions-live-framework-s-namespaces.md)). A missing mapping +stops the host from starting (a startup filter, which comes with the first `AddFaultResponses`), a +value no member names is refused rather than answered, and the README's mapping ends in a throwing +discard arm: the registration calls it for every member, so a missed member still fails at startup, +and without the arm C# reports CS8524. diff --git a/design/adr/0039-value-object-s-fault-does-become.md b/design/adr/0039-value-object-s-fault-does-become.md new file mode 100644 index 0000000..04a65bd --- /dev/null +++ b/design/adr/0039-value-object-s-fault-does-become.md @@ -0,0 +1,14 @@ +# 0039. A value object's fault does not become a validation error + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, phase 12, +spikes/ValidationFaults. + +ASP.NET Core 10's validation never sees a value that failed to bind, a +refused route or query value has lost its fault, and MVC already reports a refused body with the +converter's message. What remained, a minimal-API body, needs `ThrowOnBadRequest` for the whole +host, an exception handler in its pipeline, and the fault carried as data by a new public type in +the base package: too much host-wide change for one of four positions. Reopen it if ASP.NET hands +a binding failure to a hook without `ThrowOnBadRequest`. diff --git a/design/adr/0040-codometis-typekit-testing-holds-assertions-hand.md b/design/adr/0040-codometis-typekit-testing-holds-assertions-hand.md new file mode 100644 index 0000000..1acaa36 --- /dev/null +++ b/design/adr/0040-codometis-typekit-testing-holds-assertions-hand.md @@ -0,0 +1,17 @@ +# 0040. `CodoMetis.TypeKit.Testing` holds assertions that hand back the content + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, phase 12. + +`ShouldBeSome()`, `ShouldBeSuccess()` and `ShouldBeError()` return the value or the error, so the +test compares it with the assertions it already uses, and the package depends on no test framework: +a failure is a `TypeKitAssertionException`, which every runner reports. A failure names the error +it found and never prints a value, as `Option` and `Result` do not; an uninitialized result fails +every assertion as uninitialized, since `default(TError)` would otherwise read as an expected +first-member fault. **No generators**: one that goes through `Create` is a single filter over the +test's own property-based library (`Gen.String.Where(s => Email.TryFrom(s).IsSome())` in CsCheck), +and shipping it would tie the package to one such library. The package is new in 1.5.0, so its +project clears the package-validation baseline while that is 1.0.0, a condition that lapses on its +own when the baseline moves. diff --git a/design/adr/0041-sensitive-value-object-declares-tostring-serilog.md b/design/adr/0041-sensitive-value-object-declares-tostring-serilog.md new file mode 100644 index 0000000..fb23858 --- /dev/null +++ b/design/adr/0041-sensitive-value-object-declares-tostring-serilog.md @@ -0,0 +1,20 @@ +# 0041. A sensitive value object is one that declares `ToString()`; Serilog gets a satellite + +Date: 2026-10-11 +Status: accepted + +Context: 2026-10-11, +phase 12, spikes/SensitiveValues. + +The seam already masks every formatting and +Microsoft.Extensions.Logging path, so a marker would generate the one line a user writes, and is +not added. Serilog's `{@x}` read the public `Value` of every value object, masked or not: +`CodoMetis.TypeKit.Serilog` registers one destructuring policy, `Destructure.WithTypeKit()`, that +logs a value object as a scalar of its `ToString()`, recognised by `GeneratedValueObjectAttribute`. +Every value object is then a string in the event, as Microsoft.Extensions.Logging writes it, rather +than its wrapped value's JSON type: what it prints is the one rule, and the policy cannot tell a +masked `ToString()` from the generated one. Serilog's floor is 4.0.0, the first of the current +major, and the tests run on it. A classification on the type is advised against: the logging +generator refuses the method (LOGGEN035). + +Still open: diff --git a/design/adr/0042-2-0-0-next-release-bundles.md b/design/adr/0042-2-0-0-next-release-bundles.md new file mode 100644 index 0000000..8894435 --- /dev/null +++ b/design/adr/0042-2-0-0-next-release-bundles.md @@ -0,0 +1,14 @@ +# 0042. 2.0.0 is the next release, and it bundles the 1.x candidates + +Date: 2026-10-12 +Status: accepted + +Context: 2026-10-12, phase 13. + +The +fault responses, `Patch` and the smart-enum family ship together in the major that already +waits for Metalama 2027.0 stable, [decision 34](0034-errors-fixed-within-major-version-warnings.md)'s promotions and C# 15 unions. A 1.6.0 first was the +alternative. Every part is additive, so it would have been a minor version, but the fault mapping +would have been designed on enums with `FaultKind` attributes read by reflection, then rebuilt +once smart enums exist. The cost is time: nothing ships before the later of .NET 11's GA and +Metalama 2027.0 stable. diff --git a/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md new file mode 100644 index 0000000..a2104ae --- /dev/null +++ b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md @@ -0,0 +1,22 @@ +# 0043. Exceptions, type-argument constraints, a constructor guarantee and the text guards join 2.0.0 + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The goals were reopened and four candidates weighed. `Try` stays +out, since it is `Result` renamed, and `Result.Catch` takes its one job; `Unit` +stays out, since `Result` is the value-less shape. Constraints a type parameter cannot +declare (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`) ship as attributes and an analyzer +rule, not as an aspect, because the violation is at a use site that need not compile with +Metalama. `[EnsureParameterlessConstructor]` ships as an attribute, an analyzer rule and a code +fix, not as an inheritable aspect, because its value is the error and an introduced constructor +would demand `partial` of every implementer and be silent without Metalama. The written-text +guards ship as string extensions and a fault in the base package, with no value object and no +`.Text` package: the base package cannot declare one for the generators, a hand-written one is +what CMTK0002 refuses, a woven one in a package of its own costs a private generators reference, +a boundary exemption, a baseline override and a smoke consumer for one line per field, and the +type cannot be refined (CMTK1005) or told apart from another field of the same type (CMTK0008). +Public aspects are allowed in principle ([§5](../aspects.md)) and none exists. Clock-type analyzers and +decimal JSON converters stay out. diff --git a/design/adr/0044-2-0-0-net10-0-metalama.md b/design/adr/0044-2-0-0-net10-0-metalama.md new file mode 100644 index 0000000..90e8f6e --- /dev/null +++ b/design/adr/0044-2-0-0-net10-0-metalama.md @@ -0,0 +1,17 @@ +# 0044. 2.0.0 is `net10.0` on Metalama 2026.1; the version number names the API, never the runtime + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +Dropping a target framework is a break of its own, so a `net11.0`-only +2.0.0 would have left every consumer on the .NET 10 LTS line (supported to November 2028) on +1.5.x, without the fault responses, the closed sets or `Patch`, for a .NET 11 that is STS. The +breaks 2.0.0 actually has, [decision 34](0034-errors-fixed-within-major-version-warnings.md)'s promotions and two rules at Error, are breaks on +`net10.0`, and none of its parts needs C# 15 unions. A `net11.0` target is additive and joins a +2.x minor when a feature needs it; unions in the public surface would be two contracts under one +version, so they and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS. Metalama 2027.0 joins +the first release after it is stable, minor or major as the fabric measurement says. The +`2.0.0-preview.N` branch of 2026-10-12 becomes a preview of 2.0.0 itself from `main`; the .NET 11 +measurement is a 3.0.0 spike with no release attached. diff --git a/design/adr/0045-topic-pages-docs-compiled-like-readmes.md b/design/adr/0045-topic-pages-docs-compiled-like-readmes.md new file mode 100644 index 0000000..4e58ad5 --- /dev/null +++ b/design/adr/0045-topic-pages-docs-compiled-like-readmes.md @@ -0,0 +1,22 @@ +# 0045. Topic pages in `docs/`, compiled like the READMEs; the plan in `design/` + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase +13. + +The package READMEs stay the reference for each package, since nuget.org shows them and every +exception message and analyzer help link anchors into them. Guides, one topic each and written +around examples, are `docs/.md`, listed in the root README's "Documentation" table, which +`ReadmeSampleTests` reads: every page in `docs/` is compiled by being there (its samples are +`{file name}/{name}` in `test/CodoMetis.TypeKit.Samples/Docs`), must be linked from the table, +and the table links only pages that exist. The same code block is never in a README and a page, +since a block is compiled from exactly one region. A page's public-surface table (`option.md`, +`result.md`) is checked against the types by reflection, so a member added without a row fails. +The plan and the design notes moved to `design/`, so that `docs/` holds only what a consumer +reads and nothing there is exempt from compilation; `design/` is a solution folder. Eleven pages +came first; `merge-patches.md` and `closed-sets.md` joined them once the docs branch merged into +2.0.0 (2026-10-13). The fault responses are in the ASP.NET Core and closed-set pages, no public +aspect is planned ([decision 43](0043-exceptions-type-argument-constraints-constructor-guarantee.md) allows one, and none exists), and `migrating-to-2.md` lists what +builds or runs differently for 1.5 code. diff --git a/design/adr/0046-timeout-caught-result-catch-error.md b/design/adr/0046-timeout-caught-result-catch-error.md new file mode 100644 index 0000000..97dc9cd --- /dev/null +++ b/design/adr/0046-timeout-caught-result-catch-error.md @@ -0,0 +1,16 @@ +# 0046. A timeout caught by `Result.Catch` is the error + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +Every overload let +an `OperationCanceledException` through, and `HttpClient` throws one over a `TimeoutException` +when its `Timeout` elapses, so `CatchAsync(() => client.GetAsync(…))` threw on the most common +failure of the call the README shows it with, and a sample test that +raced a 100 ms timeout against a refused port failed whenever the timeout won (measured). A +timeout is the call failing, not the caller cancelling: an `OperationCanceledException` whose +`InnerException` is a `TimeoutException` is caught, every other one still passes through. No +`CancellationToken` overload: the shape tells the two apart without one, and a token cancelled by +the caller still surfaces as cancellation. diff --git a/design/adr/0047-firstornone-query-runs-query.md b/design/adr/0047-firstornone-query-runs-query.md new file mode 100644 index 0000000..43f504f --- /dev/null +++ b/design/adr/0047-firstornone-query-runs-query.md @@ -0,0 +1,15 @@ +# 0047. `FirstOrNone` on a query runs in the query + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The `IEnumerable` +overloads were the only ones, so `db.Orders.FirstOrNone(o => o.Id == id)` read the table and ran +the predicate in memory, with no warning (reproduced: no `WHERE`, no `LIMIT`). `IQueryable` +overloads take an expression and `Take(1)`, a break only for a predicate the provider cannot +translate, which now throws: a major's to make. `LastOrNone` and `LastOrError` on a query are +`[Obsolete(error: true)]`, as `Catch`'s task trap is, since a query has no last element until it +is ordered. EF's `FirstOrNoneAsync` lives in the satellite, in EF's namespace; under Native AOT +neither is precompiled (measured), which the EF README says with the call-site form to use. diff --git a/design/adr/0048-intended-break-suppressed-alone-baseline-stays.md b/design/adr/0048-intended-break-suppressed-alone-baseline-stays.md new file mode 100644 index 0000000..15fcdd4 --- /dev/null +++ b/design/adr/0048-intended-break-suppressed-alone-baseline-stays.md @@ -0,0 +1,14 @@ +# 0048. An intended break is suppressed alone; the baseline stays + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The plan +left the choice to the first break package validation reported, and it came with the fault +responses: `FaultProblem` takes `notnull` where it took `struct, Enum` (CP0021), so that a +closed set can be a fault type. Each intended break gets its own entry in the project's +`CompatibilitySuppressions.xml` (generated with `-p:ApiCompatGenerateSuppressionFile=true`, then +read before it is committed), and the 1.5.1 baseline keeps comparing every other member, so an +unintended break still fails the pack. Letting the baseline lapse for 2.0.0 would have passed one. diff --git a/design/adr/0049-fault-responses-client-can-act.md b/design/adr/0049-fault-responses-client-can-act.md new file mode 100644 index 0000000..c96bee9 --- /dev/null +++ b/design/adr/0049-fault-responses-client-can-act.md @@ -0,0 +1,28 @@ +# 0049. Fault responses a client can act on + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13, spikes/FaultResponses. + +The five +parts ship as proposed, with these choices measured or made on the way: +- **A kind's status is fixed**: `NotFound` 404, `Conflict` 409, `Invalid` 422 (RFC 9110 + §15.5.21, a well-formed request a rule refuses), `Forbidden` 403, `Unavailable` 503. No + host-wide table: a mapping of the host's own, calling `FaultResponse.For(kind)` for the members + it keeps, is the escape hatch, as [decision 38](0038-result-becomes-response-explicit-tohttpresult-faults.md)'s switch is. `FaultKind` starts at 1, so a + `default` kind is refused where the set is mapped. +- **`Fault`'s fault is its `Reason`**, not its `Kind` as first proposed: a member's + `FaultKind` is its `Kind`, and `fault.Kind.Kind` would have read as a stutter. A blank detail is + refused; `null` says there is none. +- **A description is the `detail`**: RFC 9457 §3.1.4 has `detail` help the client correct the + problem, and §3.1.3 keeps `title` the same for every occurrence of a type, which a status's own + title already is. An occurrence's detail replaces the member's description. +- **`IFault` is non-generic**, beside `IFaultSet`, so the satellite reads a member's kind + with an interface check whatever the fault type, and `Description` defaults to `null`. +- **Overloads by constraint live in two classes**, since C# forbids two methods that differ in + constraints alone; with the type arguments inferred, the one whose constraint fails drops out. +- **The document lists problem types in the response's description**, and `fault` stays a string + on the shared problem schema; a per-operation enum is not offered. +- **An MVC action names its member in an attribute**, `[FaultStatus(nameof(…), 422)]`, + since an attribute takes only constants; a name no member has stops the host. diff --git a/design/adr/0050-warnings-cmtk0003-alone-becomes-error-2.md b/design/adr/0050-warnings-cmtk0003-alone-becomes-error-2.md new file mode 100644 index 0000000..eecf49b --- /dev/null +++ b/design/adr/0050-warnings-cmtk0003-alone-becomes-error-2.md @@ -0,0 +1,25 @@ +# 0050. Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0 + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +Decision 34 +lets a major raise a Warning; the bar for raising one is that every report can be answered in the +code by a form that says what is meant, never by a suppression alone, and that its reach is +complete, since an Error cannot learn within the major. CMTK0003 meets both: `_ =`, a cast or a +loop variable named `_` says a drop is meant, and its reach is a return type rather than a list +of methods, complete once its 1.x gaps were closed (`Wait`, `Task.WaitAll`, a loop that never +reads its results, a lazy sequence materialised). The others stay. CMTK0005's array filled in a +loop and CMTK0006's member set in a second step (`OnInitialized`, `InitializeAsync`, a private +field `required` cannot reach) have only a suppression to say so. CMTK0008's shared identities +are answered by a cast an IDE reports as redundant, and its four known gaps would be frozen for +2.x. CMTK0009's reach is a list of framework methods, which the `net11.0` target of a 2.x minor +may lengthen, and a sequence checked for emptiness first is correct. CMTK0007 reports test +theories. The 1.5.1 baseline's verdict, over all seven packages: under package validation's +default rules one difference, CP0021 on `FaultProblem` ([decision 48](0048-intended-break-suppressed-alone-baseline-stays.md)); with the +attribute and parameter-name rules on as well, 29 compiler-emitted attributes (state machines +renumbered by members added before them, nullable-context bytes) and no renamed parameter. The +analyzer's severity and the query overloads' binding are behaviour no compatibility check reads; +the changelog states each. diff --git a/design/adr/0051-closed-set-parses-ignoring-case-only.md b/design/adr/0051-closed-set-parses-ignoring-case-only.md new file mode 100644 index 0000000..0b7bb56 --- /dev/null +++ b/design/adr/0051-closed-set-parses-ignoring-case-only.md @@ -0,0 +1,16 @@ +# 0051. A closed set parses ignoring case only when asked, and refuses members that differ only in case + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, phase 13. + +The ordinal parse stays the default and the only one JSON, the +column, binding and `IParsable` use, since what is stored or sent is the name as it is. A header +or a query string a client typed is read with `ignoreCase: true` at the call, as +Ardalis.SmartEnum's `FromName(name, ignoreCase)` is, and not through a per-set setting, which +would change what the wire accepts without a word at the call site. With the overload, two +members whose names differ only in case are one name to the parse, so CMTK1021 refuses them at +the declaration, where it already refused the first-letter case (`Open`, `open`) that gave +`Match` one parameter twice. The peers meet such pairs at run time: Ardalis throws on the first +ignore-case lookup, Thinktecture on the first `Get`, and Intellenum answers the first declared. diff --git a/design/adr/README.md b/design/adr/README.md new file mode 100644 index 0000000..4798331 --- /dev/null +++ b/design/adr/README.md @@ -0,0 +1,60 @@ +# Decisions + +One record per decision, numbered as the plan numbered them, in the order they were taken, each with +its date and the evidence that settled it. A decision is changed by a new one that names it, never by +editing the old; a record that a later one replaces says `superseded by` and the number. The notes +per area ([design](../plan.md)) link to the records they rest on. + +| # | Decision | Date | Status | +|---|---|---|---| +| [0001](0001-license-mit.md) | License: MIT | 2026-09-26 | accepted | +| [0002](0002-name-codometis-typekit.md) | Name: `CodoMetis.TypeKit` | 2026-09-27 | accepted | +| [0003](0003-analyzer-ids-cmtk.md) | Analyzer ids: `CMTK` | 2026-09-27 | accepted | +| [0004](0004-metalama-2026-1-x-latest-stable.md) | Metalama 2026.1.x (latest stable) | 2026-09-26 | accepted | +| [0005](0005-option-result.md) | Option/Result | 2026-09-27 | accepted | +| [0006](0006-result-shape.md) | `Result` shape | 2026-09-27 | accepted | +| [0007](0007-analyzer-own-package.md) | Analyzer as its own package | 2026-09-27 | accepted | +| [0008](0008-uninitialized-result-throws.md) | An uninitialized `Result` throws | 2026-09-27 | accepted | +| [0009](0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md) | EF mapping through an additive `IRelationalTypeMappingSourcePlugin` | 2026-09-27 | accepted | +| [0010](0010-stored-json-storedjsonconverterfactory.md) | Stored JSON through `StoredJsonConverterFactory` | 2026-09-27 | accepted | +| [0011](0011-openapi-schema-transformer.md) | OpenAPI through one schema transformer | 2026-09-27 | accepted | +| [0012](0012-asp0020-documented-suppressed.md) | ASP0020 is documented, not suppressed | 2026-09-27 | accepted | +| [0013](0013-first-release-s-rule-set-cmtk0001.md) | The first release's rule set is CMTK0001–CMTK0009 | 2026-09-28 | accepted | +| [0014](0014-context7-json.md) | context7.json. | 2026-10-13 | open | +| [0015](0015-option-result-wire-types.md) | `Option`/`Result` are not wire types | 2026-09-27 | accepted | +| [0016](0016-native-aot-wherever-platform-allows.md) | Native AOT wherever the platform allows it | 2026-09-27 | accepted | +| [0017](0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md) | Run-time discovery reads `GeneratedValueObjectAttribute` | 2026-09-27 | accepted | +| [0018](0018-name-per-concept.md) | One name per concept | 2026-09-27 | accepted | +| [0019](0019-concise-implicit.md) | Concise, not implicit | 2026-09-27 | accepted | +| [0020](0020-net10-0-only.md) | net10.0 only | 2026-09-28 | accepted | +| [0021](0021-first-release-1-0-0.md) | The first release is 1.0.0 | 2026-09-28 | accepted | +| [0022](0022-registration-extensions-live-framework-s-namespaces.md) | Registration extensions live in the framework's namespaces | 2026-09-28 | accepted | +| [0023](0023-value-object-key-over-integer-generated.md) | A value-object key over an integer is generated on add; one over a `Guid` is assigned | 2026-09-28 | accepted | +| [0024](0024-nested-value-objects-named-after-nesting.md) | Nested value objects are named after their nesting chain in the OpenAPI document | 2026-09-28 | accepted | +| [0025](0025-analyzer-s-roslyn-floor-stays-5.md) | The analyzer's Roslyn floor stays at 5.0.0 | 2026-09-28 | accepted | +| [0026](0026-value-object-s-json-wrapped-type.md) | A value object's JSON is its wrapped type's, byte for byte | 2026-09-28 | accepted | +| [0027](0027-no-refusal-quotes-input.md) | No refusal quotes the input | 2026-09-28 | accepted | +| [0028](0028-seams-declared-everything-else-refused.md) | Seams are declared, everything else refused | 2026-09-28 | accepted | +| [0029](0029-only-result-terror-converts-bool.md) | Only `Result` converts to `bool` | 2026-09-28 | accepted | +| [0030](0030-asynchronous-callbacks-return-task.md) | Asynchronous callbacks return `Task` | 2026-09-28 | accepted | +| [0031](0031-no-extension-object-unconstrained-t.md) | No extension on `object` or on an unconstrained `T` | 2026-09-28 | accepted | +| [0032](0032-value-object-holds-wrapped-value-alone.md) | A value object holds its wrapped value alone, and its equality is that value's | 2026-09-28 | accepted | +| [0033](0033-analyzers-run-code-metalama-transformed.md) | The analyzers run on the code Metalama transformed | 2026-09-28 | accepted | +| [0034](0034-errors-fixed-within-major-version-warnings.md) | Errors are fixed within a major version; Warnings may learn | 2026-09-28 | accepted | +| [0035](0035-sqlite-s-autoincrement-application-s-line.md) | SQLite's autoincrement is the application's line, not the convention's | 2026-09-28 | accepted | +| [0036](0036-nullable-value-object-admits-null-openapi.md) | A nullable value object admits null in the OpenAPI document wherever it appears | 2026-09-28 | accepted | +| [0037](0037-refusal-s-message-landing-page.md) | A refusal's message is its landing page | 2026-09-28 | accepted | +| [0038](0038-result-becomes-response-explicit-tohttpresult-faults.md) | A `Result` becomes a response through an explicit `ToHttpResult()`, its faults mapped in the host | 2026-10-11 | accepted | +| [0039](0039-value-object-s-fault-does-become.md) | A value object's fault does not become a validation error | 2026-10-11 | accepted | +| [0040](0040-codometis-typekit-testing-holds-assertions-hand.md) | `CodoMetis.TypeKit.Testing` holds assertions that hand back the content | 2026-10-11 | accepted | +| [0041](0041-sensitive-value-object-declares-tostring-serilog.md) | A sensitive value object is one that declares `ToString()`; Serilog gets a satellite | 2026-10-11 | accepted | +| [0042](0042-2-0-0-next-release-bundles.md) | 2.0.0 is the next release, and it bundles the 1.x candidates | 2026-10-12 | accepted | +| [0043](0043-exceptions-type-argument-constraints-constructor-guarantee.md) | Exceptions, type-argument constraints, a constructor guarantee and the text guards join 2.0.0 | 2026-10-13 | accepted | +| [0044](0044-2-0-0-net10-0-metalama.md) | 2.0.0 is `net10.0` on Metalama 2026.1; the version number names the API, never the runtime | 2026-10-13 | accepted | +| [0045](0045-topic-pages-docs-compiled-like-readmes.md) | Topic pages in `docs/`, compiled like the READMEs; the plan in `design/` | 2026-10-13 | accepted | +| [0046](0046-timeout-caught-result-catch-error.md) | A timeout caught by `Result.Catch` is the error | 2026-10-13 | accepted | +| [0047](0047-firstornone-query-runs-query.md) | `FirstOrNone` on a query runs in the query | 2026-10-13 | accepted | +| [0048](0048-intended-break-suppressed-alone-baseline-stays.md) | An intended break is suppressed alone; the baseline stays | 2026-10-13 | accepted | +| [0049](0049-fault-responses-client-can-act.md) | Fault responses a client can act on | 2026-10-13 | accepted | +| [0050](0050-warnings-cmtk0003-alone-becomes-error-2.md) | Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0 | 2026-10-13 | accepted | +| [0051](0051-closed-set-parses-ignoring-case-only.md) | A closed set parses ignoring case only when asked, and refuses members that differ only in case | 2026-10-13 | accepted | diff --git a/design/analyzers.md b/design/analyzers.md new file mode 100644 index 0000000..291d501 --- /dev/null +++ b/design/analyzers.md @@ -0,0 +1,75 @@ +# Analyzer package and rule set + +**Packaging (decided 2026-09-27).** `CodoMetis.TypeKit.Analyzers` (netstandard2.0; analyzers and +code fixes under `analyzers/dotnet/cs`). `CodoMetis.TypeKit` takes a package dependency on it. +- **Why separate:** rules and fixes can ship without a base version bump, and a consumer can take + a newer analyzer by referencing it directly. The code-fix assembly (which needs Workspaces) + stays out of a runtime library package. +- **Why still a dependency of the base package:** fabric spike consequence A. Whoever can see the + interfaces must get the guard. +- **Measured 2026-09-27 (SDK 10.0.401),** packed and consumed from a local feed: + - A project that references only `CodoMetis.TypeKit` gets CMTK0001 and CMTK0002, and so does a + project that reaches it only through another project. + - With the default `exclude="Build,Analyzers"` on the dependency, the analyzer **still** loads, + because the SDK takes analyzers from every package in the restore graph. `PrivateAssets="none"` + is kept so the dependency says what is intended instead of relying on that. + - What does drop it: a project reference with `ReferenceOutputAssembly="false"` emits **no + dependency at all**. The reference is therefore an ordinary one with `Private="false"`. +- **Guards:** `AnalyzerPackagingTests` packs both packages and reads the nuspec and the package + layout. The consumer smoke test (phase 6) asserts CMTK0001 fires, directly and transitively. + +Attributes stay in `CodoMetis.TypeKit`. The analyzer resolves them and the interfaces with +`GetTypeByMetadataName` and compares symbols ([§3](discovery.md)), so it does not reference the base package. +Inside the analyzer, rules that react to attributes are kept apart from value-object rules, so the +split stays mechanical if a generic analyzer package is ever wanted. + +**Scope.** Code that compiles but is wrong about value objects, `Result`/`Option`, types that +forbid `default`, and what a type declares by attribute because C# cannot express it (a constraint +on a type parameter, a constructor a host needs). General linting (clock types, style) stays out, since Meziantou and Roslynator +exist. + +| Id | Rule | Severity | Status | +|---|---|---|---| +| CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | +| CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | +| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | +| CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | +| CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | +| CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | +| CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | +| CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | +| CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | +| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | +| CMTK0011 | A concrete type implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a type declaring no constructor satisfies `Public`. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | +| CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | +| — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | + +**Rules that read calls see the source before weaving (measured 2026-09-28).** Metalama runs +analyzers on the unwoven source, where a call to a member the generators introduce into a value +object of the same project does not bind. CMTK0003 recognises `X.TryFrom(…)` and `x.Revalidate()`, +CMTK0007 `X.FromKnownGood(…)` (a `using static` call included) and CMTK0009 `X.TryFrom(…).OrDefault()` +by the generated member's name on a validated value object's type, which binds; the names are pinned +by the generated-surface snapshot. CMTK0001 takes a `default`'s type from the enclosing conditional or +collection when a sibling does not bind, and CMTK0006 resolves an unbound `this(…)` to the one +constructor it can mean. A local declared `var` from an unbound call, and CMTK0008 over such locals, +stay out of reach. The build-outcome consumer holds every rule to a value object of its own project, +since the verifier tests never weave. + +**Generated code (decided 2026-09-28).** The rules analyse generated code and report from it only +where a location maps (`#line`) to a `.razor` or `.cshtml` file: skipping generated code, as Roslyn +does by default, silenced every rule inside Razor components, CMTK0001 included. Anything else +generated stays unreported, which keeps EF's compiled model out of CMTK0004 (the smoke test's AOT +consumer is the measurement). `GeneratedCode.Report` is the one reporting path. + +**Transformed code (decided 2026-09-28, [decision 33](adr/0033-analyzers-run-code-metalama-transformed.md)).** Where Metalama compiles a project, source +generators run after its transformation, so the Razor compiler's output is not in the source an +analyzer sees, and neither are the members the aspects introduce. The analyzer package's +`buildTransitive` props lists the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer`, and +Metalama's compiler runs them on the transformed code. The IDE ignores the item, so live analysis +still runs on the source, where the name-based forms above remain the reach. + +**Release discipline.** Keep `AnalyzerReleases.Shipped/Unshipped.md` tracking (RS2008). A new rule +ships at Warning or Info in a minor version and is raised to Error only in a major. Consumers +build with warnings as errors, so a new Error rule in a minor version would break their builds on +update. Within a major version an Error never reports more than it did; a Warning or Info may learn +more forms in a minor version ([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md), stated in the analyzer README). diff --git a/design/aspects.md b/design/aspects.md new file mode 100644 index 0000000..55bf1a5 --- /dev/null +++ b/design/aspects.md @@ -0,0 +1,218 @@ +# Aspects + +- **Aspect-class count.** Ten classes. The Metalama Open Source edition (MIT) has no aspect-class + limit (pricing page, 2026-09-26). The unlicensed CI build confirms it (phase 6). +- **Entry points.** `ValueObjectAspect` introduces three private helpers on every value + object, and they are the only way the JSON, parsing and type-converter aspects create an + instance: `__FromJson` (a refusal throws `JsonException`), `__FromText` (`FormatException`) and + `__TryFromText` (`false`). A plain value object constructs directly; a validated one calls + `Create` through `GeneratedFactories` in the base package. The exception is the explicit + `IValueObjectMaterializer.Materialize`, by contract. `MinValue`/`MaxValue` are only generated for + a plain value object. +- **Declarations that cannot be generated** are errors, so no type is left half-generated: + CMTK1000/1001/1002 (not `partial`, not a record, a struct not `readonly`), CMTK1003 (more than one + marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a + generic value object, one that derives from another value object, a wrapped type that is not + a class, struct or enum, or one that is a value object: itself, one that reaches it again, or + any other, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares + equal only to its own type, which is not value equality), CMTK1007 (the name of the + `GetValue`/`ValueOrNull` class is taken, see below), CMTK1008 (a comparison member beside the + seam, see below), CMTK1009 (a hand-written constructor, see below). +- **No hand-written constructor (decided 2026-09-27).** A value object's one constructor is the + generated private one, and a hand-written instance constructor is CMTK1009. The implementation + aspect answers it before introducing its own, since it reads only its target: `Constructors` minus + the implicitly declared ones, measured to catch every form (the generated one's signature, any + other, one chained to the generated one, a struct's `X()`, a record's copy constructor, a + positional record's parameter list) and to leave the compiler's own and a static constructor + alone. The type's own members can still call the private constructor, as `Create` must: that is + the one trusted seam into a validated value object, stated on `IValidatedValue` and in SECURITY.md. +- **`Revalidate()` (decided 2026-09-28)** on a validated value object returns `Create(Value)`: today's + rules applied to an instance that was rebuilt without them, by the EF satellite or + `StoredJsonConverterFactory`. It is how an application finds the stored values a rule added later + refuses, which the validation-free read path otherwise hides by design. It returns what `Create` + returns, normalisation included, and a type that declares its own keeps it. +- **The companion class** holding `GetValue()`/`ValueOrNull()` sits at namespace level, as + extension methods must, and is named after the whole nesting chain: `Order.Id` gets + `OrderIdExtensions`. Named after the value object alone, `Order.Id` and `Customer.Id` both asked + for `IdExtensions` and Metalama crashed (LAMA0001). A name that a declared type or another value + object's companion already has is CMTK1007, naming it. +- **NodaTime.** Only `ValueObjectJsonAspect` handles NodaTime types, and it must not refer to + NodaTime at compile time, which would force NodaTime on every consumer. It resolves the types + with `TypeFactory.TryGetType` from the consumer's compilation, compares them by symbol, and uses + NodaTime's converters (values and dictionary keys) only when + NodaTime.Serialization.SystemTextJson is referenced too, and only where the options have no + converter of their own for the type, which wins as it does for the wrapped type. Parsing reaches + NodaTime types through their `[TypeConverter]` (`TypeDescriptor.RegisterType` and + `GetConverterFromRegisteredType`, the trim-safe lookup). +- **Explicit `IParsable`.** `bool` and `char` implement their parsing interfaces explicitly, so the + generated code calls the wrapped type's `TryParse` through `GeneratedParsing`, whose constrained + type parameters reach an explicit implementation. `Parse` is `TryParse` and a throw (below). +- **Parsing culture (decided 2026-09-27).** In the generated `Parse`/`TryParse` a null + `IFormatProvider` means the invariant culture, unlike the BCL, because the generated `ToString()` + is invariant: `Parse(x.ToString(), null)` must round-trip, and with the current culture it parsed + "1.5" as 15 in de-DE. A provider that is given is used as given. The type converter already did + this. +- **Formatting culture (decided 2026-09-27),** the counterpart: a null provider means the invariant + culture in the generated `ToString(format, provider)`, both `TryFormat`s and `IConvertible`. + Interpolation, `string.Format` and `Convert.ToString` pass null, and with the current culture + `$"{amount}"` was "1,5" in de-DE while `amount.ToString()` was "1.5". Display text in a culture + passes it: `string.Create(culture, $"{amount}")`. +- **The wrapped value's JSON contract** comes from `GeneratedJson.TypeInfo(options, builtIn)` + (decided 2026-09-27, [§11](native-aot.md)): the options' own contract when their resolver has one, so a host's + number handling and converters apply, and otherwise made in the serializer's order (a converter on + the options, the type's `[JsonConverter]`, the built-in converter the aspect names by its property + type). A source-generated context never sees what a value object wraps, and serializing through + the options needs reflection. A dictionary key goes through the same contract's converter. +- **The bytes are the wrapped type's (decided 2026-09-28, [decision 26](adr/0026-value-object-s-json-wrapped-type.md)).** Every wrapped-type family + writes and reads its values and keys through `GeneratedJsonPlan`, the converter the options have + for `T` or else the built-in (or NodaTime's) one: exactly what the serializer writes for a `T`, so a + host's converter, number handling, enum converter and `DictionaryKeyPolicy` apply, and swapping a + primitive for a value object changes no JSON. The earlier per-family readers and writers wrote + `TimeOnly` as `"13:45:30.0000000"` and `DateTime` keys with seven fractional digits, ignored a + host's converter for `Guid`, `DateTime` and integer keys, and skipped the key policy. + `JsonParityTests` compares a value object with its wrapped type for 14 families under five option + sets, as a value, a property and a key. +- **The wrapped value is written and read by its converter directly** (decided 2026-09-28), wherever + that is what the serializer would do: a value converter, and, for a number type only, no number + handling that changes the output (`WriteAsString`, named literals) or, for a string token, the + input. Otherwise through the serializer, which alone applies number handling. A nested + `JsonSerializer.Serialize`/`Deserialize` per value cost 35–40% (measured). The plan is kept per + converter instance for the options it last saw; a static plan per wrapped type cost 8% on writes. + The enum's built-in converter is made once per plan: made per call it allocated 4.7 KB per value. +- **Formatting and comparison never box** (decided 2026-09-28). The wrapped type's `ToString`, + `TryFormat` and UTF-8 `TryFormat` are called through `GeneratedFormatting`, a constrained generic + call that also reaches an explicit implementation, and comparison through `Comparer.Default`, + which the JIT specialises per type and, for an enum, compares the underlying values. Casting the + value to the interface boxed it; an optimising JIT hid that for some types and not for others + (enums), so the allocation tests run on a Debug build, where it never does. +- **JSON reads never parse by rules of their own.** A value and a key are read by the plan's + converter, which accepts exactly what the serializer accepts for the wrapped type. A `Parse` in + the generated code once let `FormatException` escape, which a minimal API answers with 500 rather + than 400, and read number keys with `NumberStyles.Any` ("1,000" as 1000). +- **What the wrapped type cannot read is refused without quoting it (decided 2026-09-28, decision + 27).** The BCL's and NodaTime's parse messages quote the input ("The input string 'SECRET' was not + in a correct format."), and a malformed NodaTime JSON value carried it in an inner exception. A + generated `Parse` is the wrapped type's `TryParse` and a `FormatException` naming the value object + and the wrapped type; where there is no `TryParse` (a static `Parse`, a string constructor, a type + converter) the exception is replaced, not wrapped; a JSON read throws `JsonException` the same way. + An overflow is a `FormatException` too. `UnreadableInputTests` feeds a secret through every strategy + and entry point and searches the whole exception chain. Out of reach: `JsonException.Path`, which + the serializer fills with an unreadable dictionary key, and ASP.NET's own binding messages. +- **`Uri` parses relative or absolute (decided 2026-09-28)**, as JSON and `UriTypeConverter` read it: + `new Uri(s)` made "/orders/7" a `file://` URI on Unix and threw for "orders/7". +- **A hand-written `ToString()` is a seam (decided 2026-09-28, [decision 28](adr/0028-seams-declared-everything-else-refused.md)).** It was replaced + silently, so a value object that hid a secret printed it. It is kept, and then no formatting + interface (`IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`) is generated, since + interpolation would reach those first; the type converter and JSON still write the wrapped value. +- **More declarations refused (decided 2026-09-28).** Each failed as a Metalama error naming nothing + the user wrote, or not at all: + - CMTK1010: a value object declared in more than one `partial` part. Metalama 2026.1 (and 2027.0 + preview) writes the override of the record's synthesized `ToString` into every part (CS0111 as + LAMA0611); no other advice overrides it (LAMA0041, LAMA0509, LAMA0500 measured). A part a source + generator adds is not in `Sources` and does not count. The repro for an upstream report is in the + phase 10 notes below. + - CMTK1011: a hand-written member or attribute the generators introduce (`From`, `Value`, + `Parse`/`TryParse`, `MinValue`/`MaxValue`, `[JsonConverter]`, `[TypeConverter]`, the interfaces), + checked before anything is introduced, as CMTK1009 is; the seams (`TryFrom`, `FromKnownGood`, + `Revalidate`, `CompareTo(TSelf)`, `ToString()`) stay allowed. Some of these were introduced with + `OverrideStrategy.Ignore` and kept silently: a hand-written `Parse` became the binding entry + point. `Create` implemented explicitly is CMTK1011 too, since the generated code calls + `{Type}.Create`. Since phase 11 also a hand-written `Equals(TSelf)`/`GetHashCode()`/ + `IEquatable.Equals`, `PrintMembers` without `ToString()`, an explicit implementation of an + interface the generators implement (CMTK1008 for the comparison interfaces), and a declared + `IConvertible`. + - `Create` is declared once, public: an explicit one beside it is CMTK1011, since a generic + `T.Create` would reach a second rule set. A base record's members count as the value object's + 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. + - CMTK1012: state besides the wrapped value, an instance field, auto-property, `required` member or + field-like event, declared or inherited. JSON, parsing and the materializer carried the wrapped + value alone while equality compared the rest. Computed and static members stay allowed. In a + referenced assembly a base's private fields are invisible, so a writable property counts there. + - CMTK1005 adds a generic wrapped type, tuples included (its equality is not value equality: a + `List` compares by reference), a value object named `Value` (CS0542), and a `file`-local one + (LAMA0001, which failed the whole project). +- **A `DateTime` is UTC on the wire (decided 2026-09-27).** Written and read, value and key, through + `GeneratedJson.AsUtc`: a `Local` value converts, and an `Unspecified` one (an offset-less string, a + column without a time zone) is taken as UTC with its digits kept. `ToUniversalTime()` read it as + server-local, so 12:00 became 10:00Z on a server at +02:00 and 12:00Z on CI. The guards switch the + process to a +05:30 zone, since CI runs in UTC. +- **`From(null)` throws** `ArgumentNullException` for a reference-type wrapped value, as + `Option.Some(null)` does. It wrapped a null that `Value` promises it never holds. +- **Enums** parse by name through `Enum.TryParse`, with a `FormatException` for an unknown name. + `Convert.ChangeType` cannot make an enum from a string, so the former `IConvertible` strategy + threw for every input while the type advertised `IParsable`. Nothing else that is convertible + lacks `ISpanParsable`, so that strategy is gone. +- **String comparison is ordinal**, so ordering agrees with the record's equality. Culture + comparison sorts "a" before "B" and ignores a zero-width space, and a `SortedSet` then dropped a + value a `HashSet` kept. +- **A nullable wrapped type is CMTK1005.** The markers' `notnull` constraint is only a warning + (CS8714), and `IValue` then failed inside the generated code as LAMA0611/0612. +- **A value object never wraps a value object (decided 2026-09-27).** CMTK1005, answered in the + fabric because it reads the wrapped types (see "An aspect never scans its namespace" below). + - **A cycle** (`sealed partial record SelfWrap : IValue`, or A wraps B wraps A) has no + finite form: the fallback JSON converter serializes the wrapped value through the options, + which is its own converter again, and a schema walk never ends. As a record class it compiled + without a word (spikes/OpenApiSchemas finding 16), as a struct it failed inside the generated + code (CS0523 as LAMA0611). The message names the chain: `it wraps itself (A -> B -> A)`. + - **Any other value object** (`Outer : IValue`) terminates, but its surface depended on + where the inner one was declared. Over one from a referenced project it got every interface; + over one from its own project it silently lacked `IParsable`, `IComparable`, `ISpanFormattable`, + `IConvertible`, `IMinMaxValue` and the type converter, and sorting it threw: the aspects of one + layer do not see what their sibling instances introduce (measured 2026-09-27). The message + names what to wrap instead (`wrap 'Guid' instead`); a validated value object applies the inner + one's rules in its `Create`. Refused rather than made to work: a surface that follows the + project layout is the silent failure this repo exists to prevent, and lifting a refusal later + breaks nobody, while adding one after 1.0.0 would. + - The satellites keep their own refusal ([§7](openapi.md)): a value object declared by hand, with the + interfaces and `GeneratedValueObjectAttribute<,>` the satellites read ([§3](discovery.md)), can still wrap itself. +- **Every JSON key goes through the wrapped type's own converter**, which knows the type's key + format where it has one (an enum by name, a `Uri` as its text) and throws `NotSupportedException` + where it has none. Writing the serialized value as the property name gave a `Uri` key quotes inside + its quotes and an enum key its number, and neither read back. +- **A record class sorts null first.** Its generated `CompareTo` and comparison operators take a + nullable operand, as `IComparable.CompareTo(T?)` and the record's own `==` do, and treat null + as smallest, where they threw `NullReferenceException`. +- **Ordering follows the wrapped type.** `CompareTo` delegates to the wrapped type's, ordinal for a + string, so it agrees with equality exactly where the wrapped type's own does. A custom wrapped + type whose `CompareTo` returns 0 for values its `Equals` tells apart makes a `SortedSet` drop one, + of the value object and of the wrapped type alike (measured 2026-09-27). That is the wrapped + type's contract to keep, and the marker interfaces' documentation says so. +- **One comparison seam (decided 2026-09-27).** A hand-written `CompareTo(TSelf)` is kept, and the + object overload, the operators and the interfaces are derived from it, as `TryFrom` is for the + factories. Any other hand-written comparison member is CMTK1008: an operator failed the aspect + (LAMA0500, naming no fix), and an object overload was kept silently beside a generated generic + one that need not agree with it. +- **An aspect never scans its namespace (decided 2026-09-27, + [spikes/ConcurrentNamespaceTypes](../spikes/ConcurrentNamespaceTypes/README.md)).** What owns + a companion name is answered in the fabric, before any introduction, and reaches the aspect + through its constructor and the aspect state. Anything that has to look beyond the aspect's own + target goes the same way. +- **Never look up the code model by name (decided 2026-09-28, same spike).** Compile-time code + finds a type or member by enumerating a collection and comparing names: no `OfName`, no + `OfExactSignature`/`OfCompatibleSignature`, no string indexer on `Fields`/`Properties`/`Events`. + Metalama builds a collection's by-name index lazily and without a lock, and runs the fabric's + factory, like the instances of one aspect layer, in parallel on one code model. An `OfName` that + overlapped another caller completing the collection returned nothing for a declared + `TakenNameExtensions`, and the build compiled a second one (CS0260) or Metalama failed the aspect + (LAMA0531). In the fabric that missed CMTK1007 in 13 of 300 builds; the move there on 2026-09-27 + had measured 0 in 48 by chance and blamed sibling introductions, which was wrong. With concurrent + build off: 0 in 200. Enumerating, which takes the collection's lock: 0 in 600, and 4 in 150 + again with it reverted. The race has no deterministic test, so `CodeModelLookupTests` + (conventions) fails on any such lookup in `.Generators`. +- **Metalama 2026.1.** Aspect state uses `IDurableRef`, which exists in 2026.1. `[Durable]` on the + `_value` template placeholder is 2027.0-only and stays out until the upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)). Build + each aspect on 2026.1 as it lands, and use no 2027.0-only API. +- **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`. Open point: ordering against a + consumer's own aspects (fabric spike D). +- **Not in scope:** a `Try`/`Unit` type ([§9](option-result.md) non-goals; an exception enters a result through + `Result.Catch`, phase 13), clock-type analyzers, and decimal JSON converters (no aspect needs them, + checked in phase 3). The text guards joined phase 13 on 2026-10-13, as string extensions in the + base package. +- **Public aspects (decided 2026-10-13, [decision 43](adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md)).** The value-object aspects are internal: the + fabric applies them, and their surface is what they generate. An aspect a consumer applies by hand + may be public when the aspect is the feature, with the attribute and its diagnostics as its + surface. None ships yet: the first candidate, a parameterless-constructor guarantee, went to the + analyzer and a code fix (phase 13), because its value is the error, which has to appear in a + project that never compiles with Metalama. diff --git a/design/discovery.md b/design/discovery.md new file mode 100644 index 0000000..ca6fac8 --- /dev/null +++ b/design/discovery.md @@ -0,0 +1,18 @@ +# Discovery is by interface + +No namespace strings, assembly-name prefixes or type-name lists anywhere. A name-based check +passes in tests and breaks for the first consumer who renames something. + +| Where | How | +|---|---| +| Analyzer, value-object rules | `CompilationStartAction` resolves `CodoMetis.TypeKit.ValueObjects.IValue`1` / `IValidatedValue`3` with `GetTypeByMetadataName` and compares `OriginalDefinition` with `SymbolEqualityComparer`. If `CodoMetis.TypeKit` is absent, nothing is registered | +| Analyzer, `[RequireCustomInitialization]` | The attribute symbol is resolved the same way | +| Analyzer, CMTK0002 | The compilation references an assembly whose name is exactly `CodoMetis.TypeKit.Generators`, ignoring case as assembly names do. That is the package's own id, which a consumer cannot rename, and a prefix or suffix match never counts. The assembly is not strong-named, so the name is the part of its identity to compare; the version must not be | +| EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` ([§4](ef-core.md)). There is no type scan and no assembly filter | +| OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata ([§7](openapi.md)). There is no referenced-assembly walk | + +Run-time code that holds only a `Type` reads the attribute rather than calling `GetInterfaces()` +([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). Its type arguments are constrained to `IValueObject` and +`IValueObjectMaterializer`, so the interface still decides what a value object is; the +attribute is how a type says so after trimming, which removes an interface nothing uses and keeps +custom attributes. diff --git a/design/ef-core.md b/design/ef-core.md new file mode 100644 index 0000000..49eb8b4 --- /dev/null +++ b/design/ef-core.md @@ -0,0 +1,77 @@ +# EF Core + +- **Materializer.** An interface in `CodoMetis.TypeKit.ValueObjects`: + `IValueObjectMaterializer { static abstract TSelf Materialize(T value); }`. The aspect + implements it **explicitly**, so it is invisible on the type's public surface and reachable only + through a constrained generic. Documented contract: *skips validation; for values this + application wrote itself; never call it on input.* CMTK0004 enforces who may call it. +- **One pair of conversions.** `ValueObjectConverter : ValueConverter`, public so a + property can also name it explicitly. Its expressions call its own public static + `ProviderValue` and `Materialize`: an expression tree cannot call a static abstract member + directly (CS8927), and EF's compiled model writes the expressions out as C# in the application's + assembly, where an internal helper did not compile ([§11](native-aot.md)). `UseTypeKit()` composes a plain + `ValueConverter` over the same expressions, the type the compiled model recreates, because a + precompiled query casts a property's converter to its design-time type. +- **No per-type comparer.** A `readonly record struct` already has value equality, and EF's default + comparer uses it. A test pins that it agrees with comparing `.Value`. +- **Application (decided 2026-09-27, spikes/EfMapping).** `optionsBuilder.UseTypeKit()` registers + an options extension. The name is deliberately not value-object specific, so a later `Option` + column mapping can join it. It adds an `IRelationalTypeMappingSourcePlugin` that answers any + `IValueObject` with the provider's mapping for `T` and the converter composed onto it. + EF consults it per CLR type wherever it maps one: property discovery, keys, foreign keys, + primitive-collection elements, query parameters. The lookup's facets are passed on. The plugin + composes the JSON reader/writer itself, with both types known through the attribute's visitor: + EF's own composition uses `MakeGenericType`, which it refuses under Native AOT, where a precompiled + query still maps a value-object parameter at run time. + - Not a replaced `IValueConverterSelector`: it maps the same, but a second library replacing the + selector (as strongly-typed-id guides recommend) would take it away, and the resulting model + error does not name the cause. Plugins are additive. This was first decided the other way on + an unmeasured assumption, and reversed by the spike the same day. + - Not a pre-convention: `Properties()` needs the list of types up front, which means a scan. + - An application that builds EF's internal service provider calls + `AddEntityFrameworkTypeKit()` on that service collection instead. +- **Translators.** `ValueObjectMemberTranslatorPlugin` / `ValueObjectMethodCallTranslatorPlugin` + are registered by the same options extension, so a consumer wires nothing else. A column + operand is **re-typed** as the wrapped type, keeping the column's store type, rather than cast: + `Convert` produced `o."Code"::text = 'ABC'`, which PostgreSQL discards but which is not a no-op + everywhere (on SQL Server, a cast to `nvarchar(max)` can stop an index seek). Any other operand + is still converted. The method translator acts only on a method whose signature is the unwrap, + from a value object (or its `Nullable`) to what it wraps: the attribute is public, and a + hand-marked `Length(this ProbeCode)` was translated as the column (`o."Code" = '3'`). Any other + method carrying it is left to EF, which refuses the call. + - **The element of a primitive collection is converted, not re-typed (decided 2026-09-28).** EF + infers a collection's element mapping from the columns that read its table expression (`unnest`, + `json_each`, `OPENJSON`, `VALUES`), and a re-typed element column handed it the wrapped type's + mapping for a collection of value objects: `o.Tags.Any(t => t.Value == "x")` and + `ids.Any(i => i.Value == o.Id.Value)` threw during translation on PostgreSQL and SQLite. EF 10's + `ColumnExpression` no longer knows its table, so the element column is recognised by the name + every relational provider gives it, `value`; the name only chooses between two correct + translations, and a table column named `Value` gets the cast (`t.value::text = 'x'`). +- **Keys over an integer are generated on add (decided 2026-09-28, [decision 23](adr/0023-value-object-key-over-integer-generated.md)).** EF's value + generation convention reads the property's CLR type, so a value-object key was never generated: an + entity added without one was stored as 0, and switching an `int` key to a value object dropped its + identity column. A convention plugin (first among the model-finalizing conventions, so a provider's + identity strategy sees it) marks a single-column, non-foreign key over `int`, `long` or `short` as + generated on add, at convention precedence. A key over a `Guid` is left alone: the application + assigns `OrderId.New()`, and a generated key that is already set makes EF take a new entity reached + through a navigation for an existing one. +- **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` + (`Properties()`) matches the property's CLR type; the README says to configure the value + object (`Properties()`). Measured 2026-09-28 for precision, maximum length, column type and + an enum conversion. +- **Stored JSON (decided 2026-09-27).** The generated JSON converter applies `Create` ([§5](aspects.md)), which + is right for input and wrong for JSON the application stored itself (an event store, a document + column): a rule added later would make old documents unreadable. `StoredJsonConverterFactory`, in + the base package because it is a System.Text.Json concern, is the opt-in for such a store's + `JsonSerializerOptions`. It hands out the generated converter in a mode only it can create + (a private constructor), so stored documents are read in exactly the format they were written + in, dictionary keys included. SECURITY.md lists it beside the EF satellite as the two + validation-free paths. +- **How far the translation is unique, measured 2026-09-27** + ([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)): + - `.Value` in a query predicate fails in Vogen 8.0.7 and in plain EF Core 10. Thinktecture + 10.5.0 has no public `.Value`. + - Querying the underlying value through a **conversion-operator cast** works in plain EF Core + 10, and that is how Thinktecture users get it. + - README claim: "`.Value` works in LINQ as it does in memory". Never "the only library that can + query by the underlying value". diff --git a/design/history.md b/design/history.md new file mode 100644 index 0000000..ca94872 --- /dev/null +++ b/design/history.md @@ -0,0 +1,920 @@ +# History: the phases + +The phase log as it was kept in `design/plan.md` until 2026-10-13: phases 0 to 13, each step with its +date and the measurements it recorded. It is closed. What is open is in the [roadmap](roadmap.md), what +was decided is in [the decisions](adr/README.md), and nothing here is read before a change: it is where +a "why" that the changelog does not hold is found. + +Status at the split: **1.0.0 released 2026-09-28** (nuget.org, GitHub release `v1.0.0`). Phases 0 to 11 are done; +what is left for 1.x is listed at the end of phase 11, and package validation holds every pack to the +1.5.0 surface. Phase 12 is 1.5.0, released 2026-10-11; 1.5.1 followed the same day with a fix to parameter schemas. Phase 13 is the next release, 2.0.0 (decisions [42](adr/0042-2-0-0-next-release-bundles.md) and [44](adr/0044-2-0-0-net10-0-metalama.md): `net10.0`, Metalama 2026.1). The decisions are in [§8](adr/README.md), Native AOT in §11. The fabric spike +([spikes/FabricSpike](../spikes/FabricSpike/README.md)), the translation comparison +([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)), the EF mapping spike +([spikes/EfMapping](../spikes/EfMapping/README.md)) and the OpenAPI spike +([spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)) are done, and so is phase 12's +`Result`-to-HTTP spike ([spikes/ResultHttp](../spikes/ResultHttp/README.md)). + +## The phases + +Each phase ends green, and its guards have been proven by seeding the defect +(`.claude/skills/verify-the-guard`). + +0. **Repo skeleton. ✅ Done 2026-09-27.** + - `Directory.Build.props`: warnings as errors, CS1591, Source Link, and package validation + without a baseline until the first release. + - `Directory.Packages.props` (central management; `spikes/` opts out), and `global.json` + pinned to 10.0.401 / `latestPatch`. + - The five shipping projects, still empty, in `CodoMetis.TypeKit.slnx`. + - The analyzer targets netstandard2.0 on Roslyn 5.0.0 (the floor), with RS2008 release tracking. + - `.github/workflows/dotnet.yml` (build and test, SHA-pinned actions, SDK from global.json) and + `dependabot.yml`. + - First guard: `MetalamaBoundaryTests`. Only `.Generators` may resolve Metalama, directly or + transitively. Proven by seeding a direct reference in the base package, which failed the base + and both satellites, and a transitive one via the EF satellite, which failed only that + satellite. +1. **Option and Result. ✅ Done 2026-09-27.** + - `Option`, `Result`, `Result`, the `Result.Success`/`Result.Error` markers, + their extensions and `RequireCustomInitializationAttribute` in `CodoMetis.TypeKit`. Tests in + `test/CodoMetis.TypeKit.Tests`. + - The throw on `Uninitialized`, a `ToString` that never prints the content, and a + `[DebuggerDisplay]` that does ([§9](option-result.md)). + - Guards, each proven by seeding its defect: + - one throw case per branching member (21), plus a completeness test that fails when a + public `Result` member is added without being classified as branching or not; + - `ToString` never contains the content, including for the markers; + - the debugger display names an existing private property and shows the content; + - the collapsing `Match` runs its success callback. + - Found in the fourth review on 2026-09-27, and fixed: `Option`/`Result` serialized as `{}` and + `{"State":1}` and read back as `None` and uninitialized, silently. Every exported struct now + carries `NotWireTypeJsonConverterFactory` ([§9](option-result.md)). Guard: with the attributes removed, 58 of the 62 + `NotWireTypeTests` fail, the other 4 pin the escape hatch and the absent-property gap. + - Found in the fifth review on 2026-09-27, and fixed: `Result` never checked its delegates, so a + null for the branch not taken passed until the other outcome first arrived (`Match(null, …)` on + every error), and `Option.ToResult` and the zips lost `Option`'s own check inside a lambda. Every + delegate is now checked before a branch is picked ([§9](option-result.md)). Guard: `NullDelegateTests` runs each case + on both branches, and its completeness test holds every delegate parameter of the assembly to a + case; with the checks reverted, 29 of its 42 tests fail. +2. **Value-object contracts and analyzer. ✅ Done 2026-09-27.** + - The contracts: `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<>`, `IValidatedValue<,,>`, + `IValueObjectMaterializer<,>`, `GeneratedFactories`, `TranslatedAsWrappedValueAttribute`, and + `GuidValueExtensions` with `New()` and `New(DateTimeOffset)`. No NodaTime. + - CMTK0001 and CMTK0002, resolved by symbol once per compilation ([§3](discovery.md)). CMTK0001 also follows a + type parameter's constraints (`default(T)`/`new T()` where `T : struct, IValue<…>`, or + `T : IValue<…>, new()`, which only a struct value object satisfies). + - The verifier tests compile against the real `CodoMetis.TypeKit` assembly, with no copy of the + contracts, and `default(Option)` is one of the positive controls. + - Packaging ([§10](analyzers.md)): `CodoMetis.TypeKit` depends on `CodoMetis.TypeKit.Analyzers`, which ships only + `analyzers/dotnet/cs`. `AnalyzerPackagingTests` packs both and reads the result. + - Guards, each proven by seeding its defect: + - a drifted metadata name silences the positive controls (10 tests for `IValue`1`, 9 for the + attribute); + - losing indirect markers (2) or type-parameter constraints (2) fails their tests; + - matching the generators by prefix or by suffix fails the look-alike cases (1 each); + - dropping the dependency, excluding its analyzers, packing the analyzer into `lib/` or + marking it a development dependency each fail a packaging test. +3. **Generators. ✅ Done 2026-09-27.** + - The ten aspects, the transitive fabric and `AspectOrder` in `CodoMetis.TypeKit.Generators`, + built on Metalama 2026.1.28 ([§5](aspects.md)). + - Every generated way into a validated value object applies `Create` ([§5](aspects.md), entry points). + - Probes in `test/CodoMetis.TypeKit.Generators.Probes`, one per JSON and parsing strategy plus + the edge cases, and behaviour tests for every generated member against them. The tests reach + the generators only through the probes, which is the transitive-fabric check. + - The generated-surface snapshot ([§6](test-strategy.md)), `GeneratedSurface.verified.txt`. + - Build-outcome tests: a throwaway consumer with declarations that cannot be generated gets + exactly CMTK1000–1009 and CMTK0001, nothing else, and no CMTK0002 beside the real generators. + - Guards, each proven by seeding its defect: + - a JSON read, `Parse` or `TryParse` that bypasses `Create` fails 7, 9 and 5 entry-point + cases; a new public factory without a case fails the completeness tests; + - `MinValue` on a validated number, a missing JSON-null or `Parse(null)` guard, and a fabric + that misses indirect markers or nested types each fail their tests, and the surface ones + the snapshot too; + - without the more-than-one-marker or the generic-type check, the build-outcome tests see an + aspect failure instead of CMTK1003/1005; + - an analyzer that stops seeing value objects fails the CMTK0001 build test, and one that + looks for a misspelled generators assembly breaks the probes build with CMTK0002. + - Found while building them, and fixed: JSON reads and parsing constructed validated value objects + without `Create`; `MinValue`/`MaxValue` wrapped an unvalidated bound; a JSON `null` or + `Parse(null)` wrapped null; `bool`/`char` (explicit `IParsable`) did not compile; a NodaTime + `LocalDate` could not be a dictionary key; an internal value object got a public extension + class; a marker reached through a derived interface would not have been generated. + - Found in review on 2026-09-27, and fixed with guards proven by reverting: an enum-backed value + object's parsing threw for every input; string ordering was culture-sensitive while equality + is ordinal; `Parse(x.ToString(), null)` did not round-trip outside the invariant culture; + `IValue` failed as an aspect bug instead of CMTK1005; a fallback JSON key did not read + back; a record class's comparison threw on null ([§5](aspects.md)); `Option.Some(null)` reported a value; + and `Result` did not accept the `Result.Error(e)` marker ([§9](option-result.md)). + - Found in the second review on 2026-09-27, and fixed the same way (39 guards fail with every fix + reverted): two nested value objects of one name crashed Metalama, and a declared + `{Name}Extensions` failed the aspect (CMTK1007 now); malformed JSON threw `FormatException`, + which a minimal API answers with 500; interpolation formatted in the current culture, so + `Parse($"{x}", null)` read "1,5" as 15; a `DateTime` without an offset was converted through + the server's time zone; `From(null)` wrapped null; `Result` held a null value or error; + `Result.Match` could not see the error ([§9](option-result.md)); a derived record-class value object failed + inside the generated code (CMTK1006 now); CMTK0001 missed `new T()` under `new()`. + - Found in the third review on 2026-09-27: a hand-written comparison operator failed the aspect + (LAMA0500) and a hand-written object `CompareTo` was kept silently beside the generated generic + one; both are CMTK1008 now, and the one seam, `CompareTo(TSelf)`, is documented and pinned ([§5](aspects.md)). + - Found by the OpenAPI spike on 2026-09-27 (finding 16): a record class that wraps itself, or + two that wrap each other, compiled without a word, and a struct cycle failed as LAMA0611. Every + value object over a value object is CMTK1005 now ([§5](aspects.md)). Guards, each proven by seeding its + defect: without the refusal 12 build-outcome tests fail (the struct cycle's LAMA0611 among + them); reporting a cycle as plain nesting fails 4 (a self-wrap then compiles again); allowing + nesting without a cycle fails 2. + - Found in the fifth review on 2026-09-27: a hand-written constructor. One with the generated + constructor's signature failed as LAMA0611, a positional record (`OrderId(Guid Value)`) as + LAMA0500 or an exception in the aspect (LAMA0041), and any other compiled without a word: it + skipped `Create`, left `Value` null, or, as a record's copy constructor, did that to `with`. + Every hand-written instance constructor is CMTK1009 now ([§5](aspects.md)). Guards, each proven by seeding its + defect: without the refusal 13 build-outcome tests fail, the LAMA errors among them; refusing a + static constructor too fails 2. +4. **EF Core. ✅ Done 2026-09-27.** + - The mapping spike first (spikes/EfMapping): an additive type-mapping-source plugin maps every + value object, with keys, foreign keys, nullable properties and primitive collections, on + SQLite and PostgreSQL, whatever the registration order, and beside a library that replaces + EF's converter selector ([decision 9](adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md)). + - `CodoMetis.TypeKit.EntityFrameworkCore`: `ValueObjectConverter`, the plugin, the + `.Value`/`GetValue()`/`ValueOrNull()` translators, `UseTypeKit()`, and + `AddEntityFrameworkTypeKit()` for an application that builds EF's internal service provider. + - Stored JSON ([§4](ef-core.md)): `StoredJsonConverterFactory` in the base package ([decision 10](adr/0010-stored-json-storedjsonconverterfactory.md)). + - Tests: the model on SQLite, SQL snapshots on PostgreSQL via `ToQueryString`, the default + comparer against `.Value`, and one Testcontainers PostgreSQL round trip covering every + wrapped-type family, stored values the rules refuse, and the translated queries. + - Guards, each proven by seeding its defect: a plugin that stops recognising value objects + fails 33 of 34 EF tests (all but the control), one that drops the column facets fails the + facet test; losing the member or the method translator + fails 4 and 3; a validating `Materialize` fails the stored-row test; a stored-JSON converter + that validates fails the stored value and key tests. The SQL snapshots first failed on the + cast `Convert` produced, which led to re-typing the column ([§4](ef-core.md)). + - Found in the fifth review on 2026-09-27, and fixed: the method translator took any static + one-argument method carrying the public `TranslatedAsWrappedValueAttribute` for the unwrap, so + a hand-marked `Length(this ProbeCode)` became `WHERE o."Code" = '3'`. It now requires the + unwrap's signature ([§4](ef-core.md)). Guard: the hand-marked test fails without the check, and the + `GetValue`/`ValueOrNull` snapshots, now with a record class's `ValueOrNull`, still translate. +5. **ASP.NET Core. ✅ Done 2026-09-27.** + - The measurement spike first (spikes/OpenApiSchemas): a schema transformer reaches a value + object that only ever appears as a property, which the earlier measurement had denied; container + elements and `TryParse`-bound parameters need handling of their own ([§7](openapi.md), [decision 11](adr/0011-openapi-schema-transformer.md)). + - `CodoMetis.TypeKit.AspNetCore`: `OpenApiOptions.AddTypeKit()`, which adds + `ValueObjectSchemaTransformer`. A value object's schema is filled in, keyword by keyword, from + the schema ASP.NET itself publishes for the wrapped type. + - Tests in `test/CodoMetis.TypeKit.AspNetCore.Tests`: a probe host with minimal APIs and MVC whose + document is read per shape (15 value-object properties across the wrapped-type families, nullable, list, array, set, dictionary, + nested containers, bodies, route/query/header/`[AsParameters]`/MVC `[FromQuery]` parameters, a + query array), each against a control that uses the wrapped type, in OpenAPI 3.1 and 3.0; the + host's enum converter and number handling, on the wire too; composition with another + transformer in either order; a host that inlines value objects; a keyword-completeness test over + every `OpenApiSchema` property. + - Guards, each proven by seeding its defect: not registering it fails 66 tests; dropping the + container branch 9, the parameter branch 18, the MVC model-metadata fallback 8; using the + parameter descriptor instead fails the MVC `[FromQuery]` object (4); keeping ASP.NET's `string` + placeholder fails the integer parameters (8); dropping the nullable unwrap 9; copying + `Metadata` 44; overwriting what a container or a keyword already had 1 and 28; sharing + collections 5; assigning a missing keyword (null) 174; without the recursion stop every + document is refused (73); without the cycle refusal the test host dies of a stack overflow. + - Found while building it: assigning `null` to `OpenApiSchema.Const` is not a no-op, it writes + `"const": null`, so every keyword is assigned only when the wrapped schema has one, and the + keyword tests compare the written schema, not the getters. + - Found in the spike and documented, not fixed: ASP0020 (an error) for a minimal-API route + parameter whose value object is declared in the same project ([decision 12](adr/0012-asp0020-documented-suppressed.md)). +6. **Delivery. ✅ Done 2026-09-27**, up to the first tag, which needs the GitHub repository and the + nuget.org policy (release.yml, "Setup, once"). + - Per-package READMEs: `src//README.md`, packed and named by `PackageReadmeFile` from + `src/Directory.Build.props`, so a new package cannot pack without one. `PackageReadmeTests` + packs every shipping project and reads the nuspec and the package. + - `test/consumer-smoke-test.sh [feed]`: packs the five packages (or takes a packed feed) and + builds three consumers outside the repository, with a private `NUGET_PACKAGES`, package source + mapping and the repository's `global.json`. `core` references only `CodoMetis.TypeKit`: Option + and Result work, no Metalama arrives, CMTK0001 and CMTK0002 fire. `layered`: a domain library + on the generators and an app that reaches them only through it; the app uses the generated + members, the transitive fabric generates a value object the app declares, and CMTK0001 fires in + the app with no CMTK0002. `host`: both satellites in a running web host; the OpenAPI document, + route binding, EF mapping, a round trip and the `.Value` SQL. It asserts on output, diagnostics + and the resolved package graph, never on an exit code alone. + - CI (`dotnet.yml`): test, then a pack job (smoke test against the packed feed, SBOMs) and a + smoke-test job that packs its own feed, the two modes a sibling repository found not to be + interchangeable. + - `release.yml`: on a `v*` tag, verify (tag against `Version`, the changelog dates the version, + full suite, pack, smoke test against the packed feed, one CycloneDX SBOM per package), then + publish through Trusted Publishing behind the `nuget` environment's approval, then a GitHub + release with the changelog section as notes and the SBOMs attached. Only the publish job can + mint a token, and it cannot write to the repository. `CHANGELOG.md` and a release checklist in + CONTRIBUTING.md. + - SBOMs exclude the analyzer's `PrivateAssets=all` Roslyn references: unfiltered, the analyzer's + SBOM listed 13 components for a nuspec that declares none (measured with CycloneDX 6.2.0). + - The unlicensed build: fabric spike finding 9 confirmed in a clean container, no license file + and no variable, the whole solution in Release and all 254 generator tests green. + - Guards, each proven by seeding its defect: the smoke test fails when the base package loses its + analyzer dependency (and the `default(Option)` build then succeeds) and when `AddTypeKit()` + does nothing (three OpenAPI assertions); a generators package without its assembly fails at + pack (NU5128), and a non-flowing Metalama in the repository's own build. + `ReleaseWiringTests`, `ChangelogTests` and `PackageDependencyTests` (17 seeds, each failing its + test): a renamed workflow or environment, a token in the verify job, repository write in the + publish job, a dropped tag or changelog check, either smoke-test mode dropped, a non-executable + script, a narrowed SBOM filter, an unpinned tool, a second publishing path, a malformed or + missing changelog section, a dated version with unshipped rules, a satellite without the base + package, a satellite depending on the generators. + - Found by the clean container: the build-outcome tests built their consumer in Debug whatever the + run's configuration, so CI's Release run would have failed 17 of them; the consumer now builds + in the test assembly's own configuration. + - Found by the smoke test: every README returned a bare fault from a method typed + `Result`, which does not compile ([§9](option-result.md)); and a host that has + `Microsoft.AspNetCore.OpenApi` only through the satellite fails with CS9137, because that + package enables its generator's interceptors in `build/`, which NuGet imports for a direct + reference only. The satellite README says to reference it, as the `webapi` template does; a copy + of Microsoft's switch in our own `buildTransitive` would drift from theirs. + +7. **Native AOT. ✅ Done 2026-09-27** ([§11](native-aot.md)). + - `IsAotCompatible` on the three run-time packages, so the trim and AOT analyzers run in every + build and a warning fails it. `AotCompatibilityTests` reads the packed assemblies' metadata; the + compile-time packages are exempt by name, with the reason. + - The consumer smoke test's `aot` consumer: a domain library and a web host with JSON, OpenAPI + and EF Core (compiled model, precompiled queries), published with `PublishAot`. No trim or AOT + warning may name a package or a woven value object, and the native binary is run and asserted + on. The workflows install clang and zlib before it, held by a wiring test. + - Found by publishing with Native AOT, each fixed and guarded: + - `StoredJsonConverterFactory` found its private constructor by reflection, which the trimmer + removed: stored JSON was **validated after all**, silently. It now asks the generated + converter through `IStoredJsonConverterSource`. + - the OpenAPI satellite described **every value object as `{}`**, silently: the trimmer removes + `IValueObject<,>` from a type's interfaces when nothing uses it, and `GetInterfaces()` found + nothing. The satellites now read `GeneratedValueObjectAttribute<,>` ([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). + - `Option`/`Result` refused JSON with the runtime's "missing native code" message instead of + their own: the refusing converter was made with `MakeGenericType`. One object-typed converter + now serves every type. + - the generated JSON converter serialized the wrapped value through the options (20 warnings in + the consumer's assembly), and a source-generated context without the wrapped type refused a + `decimal` value object outright, on the JIT too. It now goes through `GeneratedJson.TypeInfo`. + - the generated parsing of a `[TypeConverter]` type (NodaTime) called + `TypeDescriptor.GetConverter`, which requires unreferenced code. + - EF Core: the converter's read path called an internal helper, which a compiled model + (`dotnet ef dbcontext optimize`) writes out as C# in the application's assembly, where it does + not compile; a precompiled query cast the converter to its design-time type, which the + compiled model does not recreate; a value-object parameter, mapped at run time, hit EF's own + `MakeGenericType` guard composing its JSON reader/writer; and the converter was made with + `MakeGenericType`. + - Guards, each proven by seeding its defect: reverting the generated JSON to the options fails 6 + `SourceGeneratedJsonTests`, and in the smoke test's `aot` consumer the warnings assertion (8 woven + warnings) and the JSON assertions; each reflection or `MakeGenericType` call put back fails the + build under the analyzers, and suppressed with `#pragma` only, the smoke test's warnings + assertion (IL2070 from ILCompiler); without the generated attribute, the OpenAPI host under + Native AOT is `{}` again; dropping `IsAotCompatible` from a package fails + `AotCompatibilityTests` for that package; a missing or late toolchain step fails the wiring test. + - Not caught at run time: a `GetInterfaces()` lookup suppressed with + `[UnconditionalSuppressMessage]`, since the attribute keeps the interface alive ([§11](native-aot.md)). +8. **Pre-release naming and syntax. ✅ Done 2026-09-27.** + - Names ([decision 18](adr/0018-name-per-concept.md)): the wrapped value, the plain value object, the value object's own type + parameter and the delegate parameters each have one name, and the namespaces follow who uses + a type ([§1](packages.md)). Every rename is a compile error for code that used the old name; the analyzer's + metadata-name tests and the generated-surface snapshot moved with them, the snapshot by exactly + the interface rename and nothing else. + - Syntax ([decision 19](adr/0019-concise-implicit.md)): `Result.Success` (was `Result.Ok`), an `Option.None()` marker, `MapError`, + `Bind` from a valued result onto `Result`, `Or(fallback)` (was `Coalesce`), and a + `ToResult(error)` that keeps the value; the marker-lambda overloads and the collapsing `Match` + are gone. Measured first against a replica of the conversions: the marker works in a return, a + conditional beside `Some`, a `Bind` lambda and an `async` method, and the new `Bind` resolves + beside the generic one. The completeness tests forced a case for every new member; seeding + `None` out of the refusal, `MapError` past the uninitialized check, or the command into the + error branch of `Bind` fails 7, 1 and 1 tests. `ToResult` checks its error on either branch, + like the delegates, and a `Some` that accepted a null error fails its test. +9. **Before the first release: rule set, Result pipelines, performance. ✅ Done 2026-09-28.** + - The analyzer rule set ([decision 13](adr/0013-first-release-s-rule-set-cmtk0001.md)): CMTK0003–CMTK0008 beside CMTK0001/0002 ([§10](analyzers.md)). CMTK0004 + ships at Error, which only the first release can do without a major version. + - `Revalidate()` on a validated value object ([§5](aspects.md)): `Create` applied to the value it holds, for + finding the stored values a rule added later refuses. + - `Result` ([§9](option-result.md)): `MapAsync`/`BindAsync` on both shapes, and on a `Task>` a continuation + of every combinator (`MapAsync`, `BindAsync`, `MapErrorAsync`, `TapAsync`); `Zip` for two to six + results; `Sequence` and `Traverse`. All stop at the first error. + - Benchmarks (`benchmarks/`), against the wrapped types, and a spike against Vogen + ([spikes/ValueObjectPerformance](../spikes/ValueObjectPerformance/README.md)). + - Found by the benchmarks, and fixed: `TryFormat` boxed the wrapped value (32 bytes, 3.5 times a + `Guid`'s time); comparing an enum-backed value object boxed both operands (sorting 1,000 took 8 + times as long and allocated 367 KB); the JSON converter wrote and read a number through a nested + serializer call (1.35–1.40 times). All at parity after the fix ([§5](aspects.md)). + - Found while measuring the rules on woven code: Metalama runs analyzers on the source before + weaving, where a call to a generated member of a value object in the same project does not bind, + so CMTK0003 missed an ignored `TryFrom` and CMTK0007 every `FromKnownGood` ([§10](analyzers.md)). + - Guards, each proven by seeding its defect: the new `Result` members' completeness tests force a + case each (28 null-delegate cases, one uninitialized case per extension, one continuation per + combinator), and six seeds (zips short-circuiting, a delegate checked after the await, `Sequence` + enumerating past the error, `MapAsync` calling its selector on an error, a combinator without a + continuation, the second error first) fail 5, 16, 3, 2, 3 and 1 tests. Each rule's verifier tests + have a positive control per form and a negative control per exemption; 29 seeds across the six + rules each fail their test. A `Revalidate()` that skips `Create` fails both of its behaviour + tests, one of them against PostgreSQL. CMTK0004's exemption of EF's compiled model is measured by the smoke + test: with generated code analysed, the AOT consumer fails with six CMTK0004 errors in + `CompiledModels/OrderEntityType.cs`. The build-outcome consumer holds every rule to a value object + of its own project; without the unbound-call path CMTK0003 and CMTK0007 fail there. The + performance fixes are held by allocation tests on a Debug build, where the JIT never removes a + box, and a number-handling matrix; reverting any fix fails them. +10. **Release readiness for 1.0.0. ✅ Done 2026-09-28.** Four independent audits (the generators, the + satellites, the analyzers, the documentation), each confirming findings empirically in throwaway + consumers of packed packages, and the fixes below, each with a test that fails with the fix reverted. + - Release: the first release is 1.0.0 ([decision 21](adr/0021-first-release-1-0-0.md)); package tags, copyright and release notes, + held by `PackageReadmeTests`; the analyzer's Roslyn floor measured and held ([decision 25](adr/0025-analyzer-s-roslyn-floor-stays-5.md)), and the + build-time `Microsoft.CodeAnalysis.Analyzers` on 5.9.0; registration extensions in the framework + namespaces ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)), which the smoke test's consumers use without a `using`. + - `Option`/`Result`: `Ensure`, `TapError` and their continuations; query syntax with more than + one `from` (the one-selector `SelectMany` made the second `from` a compile error); + `GetValueOrNone`; `FirstOrNone()`/`LastOrNone()` without a predicate; `FirstOrError`/`LastOrError` + check their error on either branch, as `ToResult` does. The completeness tests forced a null and + an uninitialized case for each; six seeds fail seven tests. + - Generators ([§5](aspects.md)): a hand-written `ToString()` was replaced silently (a secret printed); a value + the wrapped type could not parse was quoted in the exception; the JSON differed from the wrapped + type's for `TimeOnly`, date keys, host converters and key policies; an enum-backed value object + allocated 4.7 KB per JSON value; `Uri` parsing disagreed with JSON; `ToType` failed the identity + conversion; nine declarations failed as Metalama errors (CMTK1005, CMTK1010, CMTK1011 now). Guards: + `ToStringSeamTests`, `UnreadableInputTests`, `JsonParityTests`, the allocation and parsing tests, + 18 build-outcome rows. + - Analyzers ([§10](analyzers.md)): nothing fired in Razor; `OrDefault()` and `FirstOrDefault` handed out defaults + unreported (CMTK0009); false positives on guard checks and zero-length arrays; false negatives + in the declaring project, for `x!`/`(x)`/named arguments, static comparisons, and a `Task` of a + result converted to `Task`; CMTK0001 35% faster. 42 verifier tests and six build-outcome cases + added, every seed recorded failing. + - Satellites ([§4](ef-core.md), [§7](openapi.md)): `.Value` on a collection element failed to translate; a key over an integer + lost its identity column ([decision 23](adr/0023-value-object-key-over-integer-generated.md)); nested value objects shared an OpenAPI component + ([decision 24](adr/0024-nested-value-objects-named-after-nesting.md)). The smoke test's AOT consumer inserts through an identity key. + - Documentation: every C# block of the READMEs is compiled and its stated outputs asserted ([§6](test-strategy.md), + item 6); four samples did not compile, one SQL comment was wrong, and a dozen claims were wrong + or untested (a JSON `null` refused by a record class, configuration binding under the source + generator, a JSON 400 with a message from a minimal API, "symbols for every package", …). Each + is now corrected, or backed by a test that did not exist. + - Found and documented, not fixed: a property absent from a JSON body stays `default` (System.Text.Json + calls no converter; `required`/`RespectRequiredConstructorParameters`, now in the ASP.NET README + with a test); `ConfigureConventions` for the wrapped type does not reach a value object; a + validation attribute on a value-object property lands in the shared OpenAPI component. + - Upstream: the Metalama issue behind CMTK1010, reproduced with Metalama.Framework 2026.1.28 and + 2027.0.3-preview by a five-line aspect that introduces `ToString` with `OverrideStrategy.Override` + into a `partial record struct` declared in two parts (LAMA0611 CS0111, then LAMA0613). Lift + CMTK1010 once Metalama emits the override once. + +11. **Second review, against the packed 1.0.0. ✅ Done 2026-09-28.** Five reviews (the generators, + `Option`/`Result`, the analyzers, the satellites, packaging and release), each building throwaway + consumers of the packed packages with a private package cache, and the fixes below, each with a + guard seeded to fail. + - Analyzers ([§10](analyzers.md)): where Metalama compiles a project, source generators run after its + transformation (Metalama 2025.0), and an analyzer sees only the source. Every rule was silent in + the `.razor` files of the usual Blazor layout (a web project referencing a value-object project) + and missed forms in the declaring project. The analyzer package's `buildTransitive` props lists + the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer` item ([decision 33](adr/0033-analyzers-run-code-metalama-transformed.md)). + `AnalyzerPackagingTests` holds every analyzer to it; the smoke test's layered consumer gains a + Razor library and a guard in Domain, and both build clean with the props left out. Every rule + has a `helpLinkUri` (`AnalyzerHelpLinkTests`). The README adds `[RequireCustomInitialization]` for + a struct made of value objects, and `.globalconfig` for severities in Razor, which no + `.editorconfig` section reaches (measured). + - `Option`/`Result` ([§9](option-result.md)): `Result` lost its `bool` conversion ([decision 29](adr/0029-only-result-terror-converts-bool.md)); + `TapErrorAsync(Func)` on both shapes and both continuations, since an `async` error + callback ran as `async void` and its exception ended the process ([decision 30](adr/0030-asynchronous-callbacks-return-task.md)); `TryCast` on + `object?` removed ([decision 31](adr/0031-no-extension-object-unconstrained-t.md)). CMTK0003 treats the instance `TapErrorAsync` like `TapAsync`. + Five seeds fail twelve tests. + - Generators ([§5](aspects.md)): state besides the wrapped value is CMTK1012 (a `Currency` lost in JSON, a lazy + cache that made equal instances unequal, `required` as LAMA0611 CS9035); a hand-written equality, + `PrintMembers` without `ToString()`, and explicit implementations of the interfaces the + generators implement are CMTK1011, or CMTK1008 for comparison (an explicit `IParsable.Parse` + let a generic `T.Parse` past `Create`); a declared `IConvertible` is CMTK1011 (it was LAMA0041); + a `file`-local value object below a directive or nested in a `file` type is CMTK1005 (it was + LAMA0001, failing the whole project); an explicit `Create` beside a public one is CMTK1011, since + a generic `T.Create` reached a second rule set; and what a base record declares counts as the + value object's own (an unsealed `ToString()` on the base was replaced and printed the value). + The first CMTK1012 refused every record base from another assembly (its compiler-generated + `EqualityContract`); the check now skips `[CompilerGenerated]` members. Decision 32. + - Satellites ([§4](ef-core.md), [§7](openapi.md)): a container of nullable value objects, and a nullable value object inlined + by `CreateSchemaReferenceId`, admit null as the wrapped type does, in 3.1 and 3.0 ([decision 36](adr/0036-nullable-value-object-admits-null-openapi.md)); + `InlinedValueObjectTests` compares 34 positions with the wrapped type. On SQLite an integer + value-object key is not `AUTOINCREMENT`, so the model never matched its snapshot and `Migrate()` + threw: the README documents `UseAutoincrement()`, and `MigrationSnapshotTests` checks it + ([decision 35](adr/0035-sqlite-s-autoincrement-application-s-line.md)). The READMEs state the 10.0.12 floors (NU1605 below them), `AddTypeKit()` per + document or `ConfigureAll`, that `UseTypeKit()` needs a relational provider, and + that value objects with one name in different namespaces share a component, as any type does. + - Packaging ([§6](test-strategy.md)): the SBOMs also exclude what the SDK adds (`Microsoft.NET.ILLink.Tasks`, + `NETStandard.Library`), read from restore output (`suppressParent: All`); the ASP.NET Core + satellite drops `Microsoft.AspNetCore.OpenApi.SourceGenerators`, which had baked ten unused types + into it (assembly 229 → 37 KB), held by `GeneratedCodeTests`; symbol packages are pushed in a step + of their own, since a duplicate .nupkg made NuGet skip its .snupkg on a re-run; package + descriptions are plain text, and the release notes omit the changelog heading. + - Analyzer gaps, closed before the tag: CMTK0003 reports a collection of results the statement + made (`Task.WhenAll`, `Select(…).ToList()`) and a method group of a `Task` method + converted to a delegate returning `Task` (14 seeds); CMTK0008 reports a LINQ join on two + different value objects' values, query and method syntax (8 seeds); CMTK0009 reports `Find`, + the immutable collections' own `…OrDefault`, and the async forms of `System.Linq.AsyncEnumerable` + and EF Core (7 seeds). CMTK0001 no longer reports a `default` passed to a comparison guard or an + assertion, or assigned to the `out` parameter of a `bool` Try method (11 seeds). + - Refusals, after a reviewer's note that the refusal-heavy design will draw "why does this throw?" + reports: every deliberate run-time refusal ends with a link to its subsection of the base README's + "Why does this throw?" (`Refusals`, `RefusalLinkTests`, 38 refusals), the two raised by generated + code (`From(null)`, a JSON null) through `CompilerServices` helpers; every CMTK1000–CMTK1012 + message ends with a link to the Generators README's Build errors table, since a Metalama 2026.1 + `DiagnosticDefinition` takes no help link (`BuildOutcomeTests`). `OrNull()` for an `Option` of a + value type (`ValueTypeOptionExtensions`), so the JSON refusal's advice compiles for `Option`. + A nullable `Option`/`Result` is refused with its own message, null or not: writing null threw + `NullReferenceException`, and reading an option gave the `Result` advice. A source-generated + context cannot refuse `Option?` cleanly (it would need `MakeGenericType`) and rejects the + contract when it builds it; the README says so. Decision 37. + - Documentation: the root README gains "How it compares" (when TypeKit fits, a decision table of + needs another library meets better, and detail tables against Vogen, Thinktecture, + StronglyTypedId, LanguageExt, CSharpFunctionalExtensions and ErrorOr, each claim read from source + at the named release on 2026-09-28, the next major in preview included) and "Built with Metalama". + The Generators README links to the comparison. Both are GitHub-only and can be corrected without + a release. + - Found and left for 1.x: CMTK0003 over `.Wait()` and `await foreach` (closed in 2.0.0 before the + rule became an Error, [decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)); CMTK0008 over tuple `==`, `Contains` and `is var`, and + composite join keys (a Warning, [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)). Raw Metalama errors + for a value object named after a generated member (`From`, `TryFrom`, `MinValue`), a wrapped type + whose `Parse` does not return it or that is abstract, and a public base member whose signature + matches a generated one (LAMA0500). `ToString()` of a default `Uri`/`Version` value object throws; `IConvertible` quotes + the value; `class Email : IValue` reports one of its three errors at a time; `IValue` + writes `{}`. JSON on `Option?` throws the wrong exception; a default marker converts. + `UseTypeKit()` on a non-relational provider could refuse in `Validate`. +12. **1.5.0: the boundaries. ✅ Done 2026-10-11.** Every item is additive, so it is a minor version. The + scope follows a survey of the competing libraries' trackers on 2026-10-11 (demand is small + everywhere, at most 22 reactions on one request), keeping what no library ships or what only + preview packages serve, and what follows from what TypeKit already holds to. + - **A `Result` as an HTTP response**, in `CodoMetis.TypeKit.AspNetCore`: a success as its value, a + fault as RFC 9457 problem details, with its status mapped from the fault in the host, so the + domain never references ASP.NET; and the endpoint's responses in the OpenAPI document. Mapping a + result to a response is not serializing it, so [§9](option-result.md)'s refusal stands. The most-requested open item + in the field (FluentResults #149); the packages that answer it are previews or unmaintained. + Measured 2026-10-11 in [spikes/ResultHttp](../spikes/ResultHttp/README.md), under JIT and Native AOT, + minimal APIs and MVC: an explicit `ToHttpResult()` returning ASP.NET's own + `Results, FaultProblem>`, a mapping per fault type registered in the host + (`AddFaultResponses`, which calls `AddProblemDetails()`, without which Native AOT writes no + problem), endpoint metadata recording only the fault type (MVC hands `PopulateMetadata` an empty + service provider), an operation transformer adding a problem response per mapped status, and a + startup filter that refuses a host with an endpoint whose fault type has no mapping. Returning + the `Result` itself from a handler is not offered: it documents a `Result`, and a Native AOT host + would need it in its JSON context. **✅ Implemented 2026-10-11** ([decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)): `FaultResponseTests` + (27, against ASP.NET's own `ProducesProblem` as the control, in OpenAPI 3.1 and 3.0, minimal APIs + and MVC), the README's sample and its problem JSON read from the README, and the smoke test's + Native AOT consumer. Fifteen seeds each fail their guard. The weaving review found one guard + vacuous: every host also mapped ProducesProblem controls, so a problem component the package + never added went unnoticed; a host without them now holds it. Without `AddProblemDetails()` only + the `traceId` assertion notices under JIT, so the AOT consumer is its real guard. A handler typed + `IResult` loses the endpoint's metadata, as ASP.NET's typed results do; the README says so. + - **A value object's fault as a validation error.** A body or parameter that `Create` refuses is a + `400` whose problem details name the member and the fault, as ASP.NET Core 10's validation + reports a data annotation, instead of a bare `400` from a `JsonException`. No library does this. + Measured 2026-10-11 in [spikes/ValidationFaults](../spikes/ValidationFaults/README.md): ASP.NET + Core 10's validation never sees a value that failed to bind, a refused route or query value has + lost its fault (`TryParse` says only `false`), and MVC already puts the converter's message in its + validation problem. What remains is a minimal-API body, reachable only with `ThrowOnBadRequest` + turned on for the whole host and an exception handler, and only once the converter's refusal + carries its fault as data. The README's "Binding" said `AddProblemDetails()` gives a refused body + a response body; it needs `UseStatusCodePages()` too, which it now says. **Dropped 2026-10-11** + ([decision 39](adr/0039-value-object-s-fault-does-become.md)). + - **`CodoMetis.TypeKit.Testing`**: assertions for `Option`/`Result` (`ShouldBeSome()`, + `ShouldBeError(fault)`), which a type without `.Value` needs more than any other, and generators + of valid and refused values that go through `Create`. **✅ Assertions implemented 2026-10-11** + ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)): `AssertionTests` (23), the README's block run as a test and its quoted failure read + from the README, the smoke test's core consumer installing the package. Generators are not built + ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)). + - **Sensitive value objects**: a marker that keeps a value out of `ToString()`, the debugger and + logs (`Microsoft.Extensions.Compliance` classification, structured-logging destructuring). It + extends [decision 27](adr/0027-no-refusal-quotes-input.md) from refusals to every place a value is printed. No library does this. + Measured 2026-10-11 in [spikes/SensitiveValues](../spikes/SensitiveValues/README.md): the + `ToString()` seam the generators keep already masks a value in every formatting path and every + Microsoft.Extensions.Logging path, `[LogProperties]` included, so a marker would generate the one + line a user writes. A classification on the type makes the logging generator refuse the method + (LOGGEN035). The one leak is Serilog's `{@x}`, which destructures the public `Value` of every + value object, masked or not; a destructuring policy closes it. **✅ Implemented 2026-10-11** + ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)): no marker; the Generators README's "A value that must not be printed", and + `CodoMetis.TypeKit.Serilog`. + - **`Recover` on `Result`**: the error branch back to a success, which today needs `Match`. + **✅ Implemented 2026-10-11** on both shapes, with `RecoverAsync` (an asynchronous selector, and + continuations on a pending result). It returns a `Result`, so a selector recovers from some + errors and returns the others; the completeness tests (continuations, null delegates, + uninitialized results) found every new member until each had its case, and four seeds each fail + their guard. + - Candidates once those ship, each with its own measurement: a Dapper satellite (nothing registered + per type, through `GeneratedValueObjectAttribute.Accept`), Newtonsoft.Json under [decision 26](adr/0026-value-object-s-json-wrapped-type.md)'s + parity, and configuration binding, which the configuration binder's source generator does not do + for `TypeConverter`/`IParsable` types (dotnet/runtime#83599). The CMTK0003/CMTK0008 gaps above + stay Warnings ([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)); CMTK0003's are closed in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)). + - **Ranges are not a TypeKit family**: CodoMetis.ValueRanges models them. A value object over one + (`IValidatedValue`) works with both packages as they + ship, measured 2026-10-11 in [spikes/RangeValueObjects](../spikes/RangeValueObjects/README.md): + - it weaves, so phase 11's "abstract wrapped type" gap does not cover a type that parses to itself; + - `UseTypeKit()` composes onto the `daterange` mapping in either order; + - `.Value.Contains(day)` becomes `@>`; + - the generated JSON and OpenAPI mirror `DateRange` exactly. + + No bridge package is needed. ValueRanges 9.0.0 (re-measured the same day) closes the three wire + gaps the value object had inherited: `[JsonConverter]` on every range type, ranges as dictionary + keys, and an OpenAPI transformer to copy, which reaches the value object through `AddTypeKit()`. + The re-run found a TypeKit defect, **fixed**: a value object's `TryParse`-bound parameter took the + wrapped type's JSON schema (`{}` over `DateRange`), where ASP.NET describes a parameter of the + wrapped type as `string` for anything off its own list (`EndpointModelMetadata.GetDisplayType`). + `ParameterTests` has a control for each entry of that list and one off it (`ProbeSpan`). Open: a + README section for the pattern, with no host registration needed for JSON; and CMTK1005 refusing + `RangeSet<,>`, whose equality is a value's, though `IEquatable` alone is no criterion + (`ImmutableArray`). + +13. **2.0.0: closed sets, fault responses, merge patches, exceptions, constraints and text.** In + progress: steps 1 to 9a done (2026-10-13), the preview and the release to come. Scoped 2026-10-12 ([decision 42](adr/0042-2-0-0-next-release-bundles.md)): the features proposed for 1.x ship in the major, + not as a 1.6.0 before it, because the smart-enum family changes what a fault is, and fault + responses built on enums first would be built twice. Pinned 2026-10-13 ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)) to + `net10.0` and Metalama 2026.1: nothing in it needs .NET 11, C# 15 unions or Metalama 2027.0, and + it waits for none of them. The build order, each step a commit with its tests, and a spike + before the part where one is listed: + 1. **Open the release** ✅ 2026-10-13: `Version` 2.0.0 and a `## 2.0.0 — Unreleased` changelog + section. The package-validation baseline is 1.5.1, the last release (it had stayed at 1.5.0, + which `ChangelogTests` only sees once the version being built moves on), and reports nothing + until the first intended break. After moving a baseline, restore the solution: the conventions + pack with `--no-restore`, and a baseline not yet in the local cache fails every pack. + 2. **The text guards** ✅ 2026-10-13: `StringTextExtensions` and `TextFault` in the base package, + `StringTextExtensionsTests` (the character table, pinned by code point), the README section + "Written text" with its two compiled samples. No spike. + 3. **`Result.Catch`** ✅ 2026-10-13, after [spikes/ResultCatch](../spikes/ResultCatch/README.md). + The spike settled the shape: the async trap is an `[Obsolete(error: true)]` overload over a + task-returning delegate, more specific than `Func`, so the compiler refuses it and names + `CatchAsync`; its cost is that a lambda that only throws must state its return type. No + `ValueTask` overloads: beside `Func>` they are ambiguous for every `async` lambda, as the + README already says of `MapAsync`. `Catch` spells both type arguments, and the + one-argument `Catch(Action)` is the command form. A delegate that returns null throws + rather than being caught: the value is computed inside the `try`, the success built outside it. + 4. **CMTK0011 and its code fix** ✅ 2026-10-13: `[RequireParameterlessConstructor]` and + `ConstructorAccessibility` in the base package, `RequiredParameterlessConstructorAnalyzer` in + `.Analyzers`, the fix in `CodeFixes/`, a project nested in the analyzer project's folder and + packed into the same package under `analyzers/dotnet/cs`: an analyzer assembly must not + reference Workspaces (RS1038), and a sibling under `src/` would be a package of its own to the + conventions. The fix spells the id and the property name it reads, held to the analyzer's by a + test. Named `Require…`, beside `[RequireCustomInitialization]`, since the rule requires and the + fix writes; nothing is introduced. + 5. **CMTK0010** ✅ 2026-10-13, after [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md), + run with the real rule over a library, a consumer and a Razor component: every seeded breach + reported once, the forwarding hole closed (a type parameter carries the attribute, or `struct` + and `new()` prove `[Sealed]`/`[Concrete]`), nothing for the open generic in its own body. The + attributes are `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` in the base package; a + generic name is checked where it is written, at the argument, and a call with inferred type + arguments at the call. + 6. **`Patch`** ✅ 2026-10-13, after [spikes/MergePatch](../spikes/MergePatch/README.md), in the + base package and the ASP.NET satellite. The spike settled the AOT shape: a converter type built + with `MakeGenericType` has no constructor to invoke under Native AOT, a static field read by + reflection is trimmed, an open generic converter in `[JsonConverter]` is refused by the source + generator (SYSLIB1220); what works is a registry the closed `Patch`'s static constructor + fills, which the factory runs for the type asked for (`RunClassConstructor`, one IL2059 + suppression with that measurement as its reason). A primitive inside a patch takes the + serializer's built-in converter, since the source generator never sees what a patch wraps; a + class is listed in the context. The document inlines a patch as its argument's schema, never + required, always admitting null; a value object inside a patch is described through what it + wraps, since a source-generated host has no contract for it. + 7. **The closed-set family** ✅ 2026-10-13, after [spikes/ClosedSets](../spikes/ClosedSets/README.md), + which answered the compatibility question: a generated type with static members on Metalama + 2026.1 and `net10.0`, nothing newer needed. `IClosedSet` in the base package, + `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1021, the + discovery attribute `GeneratedClosedSetAttribute` with a typed visitor, a text column in + the EF satellite and a string enum in the OpenAPI satellite. The fault bits (a kind and a + sentence per member) are step 8's, since the fault responses read them. Two reviews the same + day: the first's fixes are CMTK1019–1020 and the kept `ToString()`; the second's are CMTK0012 + (arguments by position), CMTK1021 (a member named as a generated member, or two sharing a + `Match` parameter), null delegates refused before a branch is picked, dictionary keys, and + `ClosedSetConverter`: the compiled model wrote the internal mapping's helpers out as C# + in the application (CS0122), so no closed set reached Native AOT. The smoke test's `aot` + consumer now publishes a set's JSON, its column, a parameter and a JSON column. + 7a. **The research picks** (2026-10-13): a research agent reviewed what 2.0.0 lacks against the + peers and the issue trackers, and the user took its top five, each a commit: + `FirstOrNone`/`FirstOrError` on a query and EF's `FirstOrNoneAsync` ✅ ([decision 47](adr/0047-firstornone-query-runs-query.md)); a closed + set's `[TypeConverter]` and `IParsable` ✅ (explicit, since `Parse(string)` is its surface; the + reflection binder now binds a set; the binder's source generator reads no type converter, + dotnet/runtime#83599); Serilog for closed sets ✅ (measured first: `{@Tier}` logged + `ProbeTier { Rank: 2, Perk: "Priority support.", … }`); continuations of a + `Task>` ✅ (the `Task` set's shapes: no `OrAsync`, no `MatchAsync`, no + `ValueTask` receiver); `Transpose`, `SingleOrNone` and `ElementAtOrNone` ✅ (more than one is a + refusal, as `SingleOrDefault`'s; a negative index on a query is `None` without a query, since + `Skip(-1)` skips nothing). + 8. **The fault responses** ✅ 2026-10-13, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), + whose candidate was the satellite itself and whose thirteen claims are its tests and the smoke + test's AOT consumer. In the base package: `FaultKind`, `IFault` and `IFaultSet` (a kind + and an optional description per member), and `Fault` (a reason and a detail, refused + by JSON and by CMTK0001 when `default`). In the ASP.NET satellite: one internal `FaultType` + for an enum and a closed set alike, `FaultResponse` (status, `type`, `title`), + `AddFaultResponses()` by kind, `WithFaultStatus` and `[FaultStatus]`, + `ToHttpResult(success)`, `IFaultFeature` and `FaultResponseOptions.QualifiedFaultNames` + ([decision 49](adr/0049-fault-responses-client-can-act.md)). `FaultProblem`'s constraint relaxed to `notnull`, the first difference + the 1.5.1 baseline reports, suppressed alone ([decision 48](adr/0048-intended-break-suppressed-alone-baseline-stays.md)). + 9. **The breaks** ✅ 2026-10-13 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)). CMTK0003 is the one Warning raised to Error, after + its gaps were closed: `Wait` on a task the expression made, tasks of results made in the + statement converted to plain ones (`Task.WaitAll`, `WaitAny`), a `foreach` or `await foreach` + whose body never reads the results its header made, and a lazy sequence of results a call made, + materialised and dropped (eleven seeds, each failing its guard; an explicit cast to plain tasks + stays silent, as a cast to `Task` does). The release-tracking file + records the change (RS2001 refuses a descriptor that disagrees, RS2007 a change that is none), + `AnalyzerHelpLinkTests` holds the README's severity column to every descriptor, and the + build-outcome consumer no longer raises CMTK0003 itself, so its reports there prove the + default. CMTK0010–0012 needed nothing: they ship at Error in this major and report only on + what it adds. The 1.5.1 baseline, run over all seven packages with the attribute and + parameter-name rules on as well, reports the one suppressed difference and compiler-emitted + attributes only. + 9a. **A review's findings** (2026-10-13), each a commit: + - **`Catch` over a task** ✅: the command form `Catch(Action)` took a delegate + returning a task and discarded it, an `async` lambda as `async void`; a task spelled as the + type argument (`Catch, …>`) and an inferred `ValueTask` were a success holding the + task. `Catch(Func)` is refused at compile time, and so is + `Catch(Func)`, any value the command form would discard, which is the + only overload a `ValueTask` reaches (the spike's ambiguity rules out `ValueTask` + overloads beside `Task` ones: measured again, every `async` lambda became CS0121). A task + as the value, spelled or inferred, is refused at run time before the delegate runs, and in + `CatchAsync` a task whose value is a task. Two-type-argument refusals were measured and + dropped: they make a lambda that only throws, or returns `null!`, ambiguous, and the calls + they would refuse are already the compiler's conversion errors. + - **A patch's schema refused or admitted the wrong values** ✅, measured by validating values + with JsonSchema.Net (test-only) against the document, 3.0's `nullable` read as 3.0.3 defines + it: a string enum's patch was `"type": "null"` beside its names and admitted nothing, a + closed set's listed no null in its enum and refused it, and `Patch` was + `"type": "null"` and admitted only null. Null now joins what restricts the values: the + enum's list, the type, or, for a schema combining subschemas, a branch beside them. + ASP.NET's own polymorphic schema needed no branch, its derived schemas stating no type; a + host's `oneOf` of typed schemas does, and guards it. + - **CMTK0010 over a method group** ✅: `Action run = Runner.Run;` infers the + type arguments from the delegate, with no generic name written and no call, and was + silent. Every method reference is checked as an inferred call is, at the group; a group + with its arguments written stays the generic name's, reported once. + - **A closed set's `All` was writable** ✅: it was the backing array, so a cast to + `IList` or `T[]` replaced a member that `All`, the JSON schema's enum and every + enumeration then listed, while `Parse`, which reads the fields, answered the original. It + is the array wrapped by `Array.AsReadOnly`, whose writes throw `NotSupportedException`. + - **Invisible text passed `AsText()`** ✅: a value of joiners alone (`"\u200D"`), of variation + selectors alone (filed as non-spacing marks, so no category test reached them), or of a + lone combining mark rendered nothing and was text. A value now needs a base character, and + a variation selector stands only directly after one, so emoji presentation, a flag, a + keycap and an ideographic variant survive; a joiner is not placed, since an Arabic letter + shown in its joining form on its own ends with one. + - **The docs** ✅: the README no longer lists fault responses (documented in the ASP.NET Core + README and guide since step 8) or public aspects (none planned) as still to come; + `docs/migrating-to-2.md` lists what builds or runs differently for 1.5 code, compiled like + every page; and the Native AOT page states the query helpers' limitation, which the smoke + test's `aot` consumer now measures: the base package's `FirstOrNone` on a query and the + satellite's `FirstOrNoneAsync` throw "Query wasn't precompiled", and the operators written + at the call site run. + 9b. **A second review's picks** (2026-10-13): two outside reviews of the release were checked + against the code (one named `Choose`, result aggregation and a dictionary lookup as missing, + where `Zip`, `Sequence`, `Traverse` and `GetValueOrNone` exist, and read CMTK0006 as a `.Value` + guard), and the user took what held, each in this release: + - **`Choose`** ✅: on a sequence with a selector answering an option, and on a sequence of + options; lazy, the arguments checked at the call (F#'s `choose`, CSharpFunctionalExtensions' + `Choose`). + - **`Patch.Map` and `Traverse`** ✅: a patch over what the request sent, validated into a + patch over the domain's type with `Create`'s own fault, a clear and an absence passing + through, `Option.Traverse`'s pattern; a `Map` selector answering null is refused, since + a clear is the Null state and never a value. + - **`TraverseAsync`** ✅, sequential: one operation awaited at a time, in order, nothing + started after the first error; the token overloads check it before each operation and hand + it on, and a cancellation propagates, as `Catch` lets it. Concurrency stays explicit, + `Task.WhenAll` and `Sequence()`. + - **Patch assertions** ✅ in `.Testing`: `ShouldBeValue()` hands the value back, + `ShouldBeNull()` and `ShouldBeAbsent()` name the state, never the value. + - **A closed set parsed ignoring case** ✅ ([decision 51](adr/0051-closed-set-parses-ignoring-case-only.md)): `Parse(name, ignoreCase)` and + `TryParse(name, ignoreCase, out member)`, opted into at the call; two members whose names + differ only in case are CMTK1021, which subsumes the `Match`-parameter collision. + - **The README** ✅ refreshed for 2.0.0: the status, the comparison tables' version, the pitch + naming closed sets and the release's other additions, and a closed-set comparison against + Ardalis.SmartEnum 8.2.0, Intellenum 2.0.1 and Thinktecture.Runtime.Extensions 10.5.1, read + from source and nuspecs on 2026-10-13. + - Not taken: error accumulation and a `Combine` ([§9](option-result.md) non-goals). A stable external name for a + closed-set member, apart from its C# identifier, touches parsing, JSON, EF, OpenAPI and fault + identifiers at once and needs a spike first; it is 2.x. + 9c. **An outside review before the tag** (2026-10-13, a separate Opus 5.5 session over the three + commits and the library), its must-fix findings each fixed with a seeded test: + - A closed set in two partial files listed the second file's members as null in `All` (a field + initializer runs with its own part); `All` is filled by the names initializer, after every + part's fields. Two nested sets of one simple name shared a `QualifiedName` prefix and one + OpenAPI component; both are the nesting chain. A non-public static field of the set's type + was an instance that is no member; CMTK1017. The nested converters are sealed. + - A closed set was a string enum as a body property only: a route or query parameter was a bare + `string`, a container's element was missing. The transformer takes the value-object + transformer's three positions. + - `ElementAtOrNone(^n)` on a query read every row through the sequence overload; refused as + `LastOrNone` is. `fault == reason` on a `Fault` compiled and was false with a detail; + the mixed forms are refused, naming `Reason`. `CatchAsync` over a delegate returning no task + was an error holding a `NullReferenceException`; it throws before awaiting. + - CMTK0010 reported doc-comment crefs and called arrays not sealed; a method implementing or + overriding a constrained method without the attribute let a call through the class pass. + CMTK0003 missed a result task picked by a conditional or a `switch` expression and a method + group answering a collection of results: [decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)'s "complete" was not, and is now + measured by those forms too. + - Documentation: the claims the review found misleading corrected (a set as a parameter, the + `Index` form on a query, "everything works" under Native AOT, `AsText()` and line breaks, + `CatchAsync` over a null task, the `ignoreCase` overloads and binding, the Testing messages' + error, the EF limitations' count), the migration guide's name list completed, `Fault` under + "Not wire types", `WithTitle`, the command `ToHttpResult(success)` and `[FaultStatus]` on a + class documented, the package descriptions naming closed sets, and the prose it flagged. + - Can wait for 2.1, recorded here: the fault name is sent even for a member without a + description; `Uri.ToString()` unescapes the problem `type`; the CMTK0011 code fix on a partial + type with a primary constructor elsewhere, and its doc comment beside an attribute; CMTK0003 + over `WhenAny` of held tasks; `Choose`'s `notnull` receiver; `Patch` under `Traverse`. + 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test + against it, then the release. + - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` ([§1](packages.md)), the + release's headline. A member is a named instance with constant data. The generators emit `All`, + parsing and formatting by name, JSON, an OpenAPI string enum, and a `Match` with one required + delegate per member. C# never proves an enum `switch` complete; it only warns about a missing + discard (CS8524). As a fault type, the family carries what `FaultKind` (proposed 2026-10-11, + folded in here) would have read from attributes: + - **A kind per member**: NotFound, Conflict, Invalid, Forbidden or Unavailable, nothing + HTTP-specific, from which `AddFaultResponses` derives a status without a hand-written switch. + It is a property set where the member is declared, so Native AOT reads it without + reflection. The explicit mapping still overrides it, so [decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s switch remains the + escape hatch. + - **An optional sentence per member**: the fault's remedy as a person reads it. It is optional + because a refusal that must not explain itself, a credential's or a secret's, has none on + purpose. + - **A generated qualified name** (`OrderFault.NotFound`) beside the bare one, so two fault types + that share a member name stay apart in one client. + + A smart enum names a fault. It does not replace an error that carries data: a closed set of + shapes (`NotAmendable(Status)`, `OutsidePeriod(Date)`) is what C# 15 unions are for. Translating + one names a smart-enum member, and what varies per occurrence travels as + `Fault.Detail` (below). A spike must answer: + - **Which status for each kind**, and whether a host may change a kind's default. + - **The startup check holds**: a member with neither a kind nor an explicit mapping still stops + the host. + - **A generated type with static members**, not a C# 15 union: 2.0.0 is `net10.0` (decision + 44), and a union-shaped closed set is 3.0.0's question. + - **Persistence**: an EF column by name, mapped by `.EntityFrameworkCore` as value objects are. + - **Fault responses a client can act on**, proposed 2026-10-12. Decision 38's response carries a status and the fault's bare name, and its success is + `200` or `204`. An application whose client shows a refusal to a person, creates resources, or + reads with an ETag has to leave `ToHttpResult()` for each of those endpoints. The libraries + surveyed on 2026-10-12 map centrally too, but none models `Location` or `ETag`: Ardalis.Result + 10.1.0, ErrorOr 2.1.1 and FluentResults. Ardalis and FluentResults allow a per-call status. RFC + 9457 §3.1.1 makes `type` the primary identifier, and §3.1.4 has `detail` help the client correct + the problem. Five additive parts, each opt-in, so [decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s default response is unchanged: + - **A response per member**: `AddFaultResponses(f => FaultResponse.Status(409).WithType(uri))`, + an overload beside the `int` one, checked for every member at registration as that one is. It + adds `type` and `title`, which name the problem type and nothing of the input. + - **A sentence per occurrence**: `Fault(TFault Kind, string? Detail)`, a record struct in + the base package. `Result>` infers `TFault` from a type argument, which an + `IFault` constraint could not, since C# infers nothing from a constraint. The mapping and + the document stay keyed on the enum. The sentence is the application's, so "the problem names + the fault, never the input" becomes the default rather than a guarantee, and the README says so. + - **A status per endpoint**: `.WithFaultStatus(OrderFault.NameTaken, 422)` as endpoint metadata, + read by `FaultProblem`, the operation transformer and the startup check. The same fault can + mean 404 on one route and 409 on another (RFC 9110 §15.5.10 defines 409 by the target resource). + - **The success the endpoint chooses**: `ToHttpResult(order => TypedResults.Created(uri, order))`, + returning `Results>`, so ASP.NET still describes the success. An + ETag needs a result of the application's own, and this is the hook for it. + - **The body the host shapes**: the fault reaches `CustomizeProblemDetails` as a request feature + (`IFaultFeature`: the fault type, the member, the detail), so a host can add extensions such as + an error code without the package naming any, plus an option to qualify `fault` as + `OrderFault.NotFound`, since two enums can share a member name in one client's switch. + + A spike must answer, in [spikes/FaultResponses](../spikes/FaultResponses/README.md): + - **Native AOT** for each part. + - **One reading of a fault.** The mapping, the startup check and the document read a fault type + through one internal abstraction, never `Enum` directly, so an enum fault and a smart-enum + fault (above) take the same path. + - **Inference**: `ToHttpResult()` over `Result>` and over `Result` + resolves without type arguments and without an ambiguity between the two. + - **The document**: a per-endpoint status is listed for that operation only, a `type` URI appears + in it, and `fault` stays a string on the shared `ProblemDetails` component. A per-operation enum + is optional, never a union over every fault type, because a fault type added later would change + every operation's schema. + - **The startup check** refuses a `WithFaultStatus` for a fault type the endpoint does not answer + with, and a member that the mapping, its kind and the endpoint all leave unmapped. + - **MVC**: the endpoint metadata reaches the transformer, where finding 4 found + `PopulateMetadata` handed an empty service provider. + - **`CustomizeProblemDetails` sees the feature** under JIT and Native AOT, and the feature is + absent for every problem `ToHttpResult()` did not write. + - **`Patch`, a field of a JSON Merge Patch (RFC 7396)**, proposed 2026-10-11 for 1.x; built 2026-10-13 (step 6 above answers the questions). It has + three states: absent, null, or a value. System.Text.Json cannot tell an absent property from a + null, so every PATCH endpoint rebuilds this type by hand. It is a wire type on purpose, beside + `Option`, which still refuses JSON ([decision 15](adr/0015-option-result-wire-types.md)): `Option` says whether a value exists, `Patch` + whether a request mentioned it. A spike must answer: + - **`default` is a state.** The serializer calls no converter for an absent property, so + `default(Patch)` has to mean "absent". That makes this the one type of the package whose + `default` is meaningful. Is it exempt from CMTK0001 by its contract, and does that stay + clear of `[RequireCustomInitialization]`? + - **Native AOT.** A converter factory over an open `T` is `MakeGenericType`, which the refusing + converter avoided only because it never reads `T`. Find what works under a source-generated + context: an attribute per closed type, or a generated converter. + - **Value objects.** `Patch`: a JSON null and a value `Create` refuses each give the + response [decision 39](adr/0039-value-object-s-fault-does-become.md) measured. + - **The OpenAPI document.** The property is not required, and admits null exactly where `T` + does, in 3.1 and 3.0. + - **Applying it.** Without reflection over the target ([§11](native-aot.md)): `patch.Name.IfPresent(v => order.Name = v)`, + not an `ApplyTo(entity)` that walks properties. Optionally an analyzer for a `Patch` that is + bound and never read. + + It belongs in the base package, its OpenAPI part in the ASP.NET satellite. It is not ASP.NET + Core 10's JSON Patch (RFC 6902, a list of operations). + - **`Result.Catch`: an exception as an outcome**, proposed 2026-10-13. A call that throws has no + way into a `Result` today, so every caller writes the `try`/`catch` by hand, and a `Try` + type would be `Result` under a second name ([§9](option-result.md)). Four factories on the `Result` + static class, each returning a concrete result rather than a marker, since the delegate fixes + `T`: + - **`Catch(Func)`** to `Result`, and **`Catch(Action)`** to + `Result`, the value-less shape, which is why no `Unit` is needed. + - **`Catch(Func)`** to `Result`, catching that type and + letting every other exception through, so the error type says what was expected to fail. + - **`CatchAsync`** for each, over `Task`, `Task` and `ValueTask`. + - **A cancellation is not an outcome**: `OperationCanceledException` passes through every + overload. Caught, a cancelled request would read as a failed one and `Recover` would run on + it. + + `Map`, `Bind` and the rest stay as they are: a selector that throws propagates, as it does + now, so the one place an exception becomes data is the one that says so. A spike must answer, + in [spikes/ResultCatch](../spikes/ResultCatch/README.md): + - **Inference**: `Result.Catch(() => int.Parse(s))` infers `T`; `Catch` needs + both type arguments spelled, since C# infers all or none, unless a different shape avoids it. + - **The async trap**: `Result.Catch(() => client.GetAsync(url))` binds `T` to a `Task` and + catches nothing the task throws. Whether an `[Obsolete]` error overload, a CMTK rule, or + both, refuses it. + - **`ToHttpResult()` over `Result`** is a compile error, since an exception is + not a fault a client acts on. + - **Native AOT**: nothing new is reflected, and the smoke test's `aot` consumer says so. + - **Constraints a type parameter cannot declare**, proposed 2026-10-13. C# has `where T : class`, + `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` or `record`. A generic + base whose type argument must be an abstract record, such as the command or event family of an + aggregate, gets no help from the compiler, and the closed type that violates it compiles. + Attributes on a generic parameter in the base package, `[Abstract]`, `[Sealed]`, `[Record]` and + `[Concrete]`, and one analyzer rule, CMTK0010, reported at every closed use: a base type, an + implemented interface, a field, a parameter, a `typeof`, a call with its type arguments + inferred. An analyzer rather than an aspect, because the violation is at the use site, which + need not compile with Metalama, and the base package is Metalama-free ([§3](discovery.md)); an aspect on the + parameter would see the declaration and never the use. A spike must answer, in + [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md): + - **Forwarding**: `class Sub : Base` where `Base<[Abstract] T>` is a violation unless `U` + carries `[Abstract]` too, or the hole is the whole rule. + - **Every use site** is reached, inferred type arguments and generic methods' parameters + included, and nothing is reported twice for one site. + - **Reflection** (`MakeGenericType`) is out of reach, and the README says so. + - **Error in a major version** is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md); the rule never ships at Error in a minor. + - **`[RequireParameterlessConstructor]`** (proposed as `Ensure…` 2026-10-13; `Require…` as built, beside `[RequireCustomInitialization]`). A type the host creates by + reflection, an endpoint, a module, an entity configuration, needs a parameterless constructor + of a given accessibility, and C# checks that only through `new()`, which demands public. An + attribute in the base package, on an interface or a base class, and an analyzer rule, CMTK0011, + on every concrete type that implements or derives from it, directly or through another type, + with a code fix that writes the constructor. An analyzer and not an aspect, although an + `[Inheritable]` aspect introducing the constructor was the first shape considered: + - **The check is the feature.** The constructor is one empty line; the value is the error at + the type that lacks it, and that error has to appear in a project that never compiles with + Metalama, where an inheritable aspect is silent, this repository's characteristic failure. + - **A code fix beats an introduction.** An introduced constructor demands `partial` on every + implementer and is invisible in the source; the fix writes it where the reader looks, and a + type that declares no constructor needs nothing, since it already has a public one. + - **What the rule checks**: a parameterless constructor of the required accessibility exists, + or none is declared and public was asked; a record's copy constructor is not one; a wrong + accessibility is reported at the constructor, with the fix changing it; an abstract type or + an interface is not checked, only its concrete descendants. + - **Public aspects stay possible** ([§5](aspects.md)): an aspect that is itself the feature may be public, + with the attribute and its diagnostics as its surface. This one is not the first, because + its feature is the check. + - **Written text**, proposed 2026-10-13. Trimming, refusing the characters that render as + nothing and composing to NFC is written again in every application that stores what a person + typed, and `IsNullOrWhiteSpace` is what it usually gets, which lets a zero-width space, a Hangul + filler and NUL through, and keeps a name written with `ü` and the same name written with `u` and a + combining diaeresis as two rows of a unique index, rendering alike. Two string extensions in the base package, each returning + `Result` with `TextFault.Blank` or `TextFault.Hidden`: + - **`text.AsText()`**, for text a person wrote: trimmed, every space separator folded to + `U+0020`, NFC-composed, every character that renders as nothing refused, the two zero-width + joiners tolerated because they spell Persian, Arabic and Indic scripts and emoji sequences. + - **`text.AsIdentifier()`**, for a value another system issued, a subject claim, a session id: + trimmed, nothing else altered, since canonicalising somebody else's identifier changes an + attested value, and the joiners refused too, since no issuer needs one. + - **A null receiver is `Blank`**, since the extension is the door a bound request property goes + through, and binding enforces no non-nullability. + - **Two faults, not one**, because a required field makes `Blank` an error and an optional one + makes it absence; `Hidden` is a refusal in both. + - **The scan is by `Rune`**: a `char` loop reports `Surrogate` for a character above the BMP and + misses the tag characters used to hide text in text; an unpaired surrogate is `Hidden`, not a + throw, since `Normalize` and a database driver both throw on it. + - **Not refused**: unassigned code points, since .NET's tables lag Unicode and a name spelled + with a newly assigned character would be refused until the SDK caught up; confusable letters + across scripts, which is a comparison-key question and not a rejection. + + No value object ships with them, and no `.Text` package either (weighed 2026-10-13). The base + package is Metalama-free, so it cannot declare one for the generators, and a hand-written one is + what CMTK0002 exists to refuse. A package shipping a woven one would reference `.Generators` + with `PrivateAssets="all"` to keep Metalama away from its consumers, and would need an + exemption in the Metalama-boundary conventions, a package-validation baseline override, a + smoke consumer of its own under Native AOT, and a third state in the README's Metalama column. + All of that buys one line per field, and the type is a dead end: a field that later needs a + length limit cannot wrap it (CMTK1005) and is rewritten over `string`, and two fields of one + shared type lose what CMTK0008 and assignment give two named types. A consumer declares its + own in one line, `Create` being `value.AsText().Map(…)`, and names it for the field + (`Description`, `DisplayName`). The README shows that line. The extensions live in a class + named `StringTextExtensions`, so a consumer's value object named `Text` has no companion-class + collision with them (CMTK1007) and nothing is ambiguous with `System.Text`. If demand for a + ready-made type appears, a minor-version `CodoMetis.TypeKit.Text` holding `WrittenText` and + `IssuedIdentifier` under those conditions is additive; removing one after shipping is not. No + spike: every claim is a unit test over a string. + - **What 2.0.0 breaks** ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)): CMTK0003 at Error, the one Warning raised; CMTK0010–0012 + new at Error; `FaultProblem`'s constraint, suppressed alone against the 1.5.1 baseline + ([decision 48](adr/0048-intended-break-suppressed-alone-baseline-stays.md)); `FirstOrNone`/`FirstOrError` binding to the query overloads, and `LastOrNone` + on a query a compile error. + - **What it does not wait for** ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)): Metalama 2027.0 stable, .NET 11 and C# 15 unions. + The compiler-made union types have a public `object? Value` and are serialized by + System.Text.Json, so `Option`/`Result` take part in their pattern matching only after a spike + shows neither returns, and that spike belongs to 3.0.0. + - **What comes after**: the Metalama 2027.0 upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)) joins the first release after + it is stable, as a minor if a consumer's own aspects beside the packed generators still build + and weave, measured as the fabric spike measured 2026.1 against the 2027.0 preview, and as a + major otherwise; `[Durable]` returns to the `_value` placeholder with it. A `net11.0` target + joins as a second target in a 2.x minor when a feature needs it, at the cost priced on + 2026-10-12: EF Core 11 and Microsoft.AspNetCore.OpenApi 11 in the satellites and a Native AOT + smoke run per target. C# 15 unions and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS + (November 2027) or to the end of .NET 10's life, whichever its consumers need. + - **The preview** (`2.0.0-preview.N`) is 2.0.0's own, from `main`, on the .NET 10 SDK and + Metalama 2026.1, published to nuget.org once the closed-set family and the fault responses + exist, so the surface gets consumer feedback before the tag. The release workflow takes a + preview tag as it is, since `v2.0.0-preview.1` passes the tag check against `Version` and the + changelog check against a dated `## 2.0.0-preview.1` section. The measurement of unions and + Metalama 2027.0 on the .NET 11 SDK moves to a 3.0.0 spike on a branch of its own, with no + release attached. diff --git a/design/native-aot.md b/design/native-aot.md new file mode 100644 index 0000000..b1d20ac --- /dev/null +++ b/design/native-aot.md @@ -0,0 +1,38 @@ +# Native AOT + +**Decided 2026-09-27 ([decision 16](adr/0016-native-aot-wherever-platform-allows.md)), measured by publishing consumers with Native AOT** (SDK 10.0.401, +ILCompiler 10.0.12, osx-arm64), JSON through source-generated contexts, OpenAPI in a slim web +host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativeaot`. + +- **What the analyzers can and cannot see.** `IsAotCompatible` runs the trim and AOT analyzers on + the packages' own code, and warnings are errors. The generators' product is compiled in the + consumer, where only the consumer's publish sees it, and the analyzers cannot tell that a run-time + path still works: the two worst findings (stored JSON validated, every schema `{}`) produced no + warning at build time on the JIT. So the smoke test publishes with Native AOT and runs the binary. +- **Trimming removes interfaces nothing uses.** ILCompiler keeps an interface on a type only if the + interface's definition is used somewhere; `typeof(IValueObject<,>)` does not count. Neither + `[DynamicallyAccessedMembers(Interfaces)]` on the interface nor on the value object kept it (both + measured). Custom attributes are kept, which is why discovery reads + `GeneratedValueObjectAttribute<,>` ([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). +- **And keeps them once anything uses the interface.** The attribute's own constraint does, and so + does the EF satellite's converter: with the attribute generated, a `GetInterfaces()` lookup found + `IValueObject<,>` again under Native AOT, and without it the same host described every value object + as `{}` (both measured). That is ILCompiler's behaviour, not a contract, so the smoke test cannot + catch a return to `GetInterfaces()` at run time; the analyzers' IL2070, an error in the packages' + build, does. +- **JSON.** The generated converter's wrapped value goes through `GeneratedJson.TypeInfo` ([§5](aspects.md)), so a + context lists the value objects and never what they wrap. A value object wrapping a type the + serializer has no converter for (a class of the application's) still needs that type in the + context, and the serializer's refusal names it. +- **OpenAPI.** The host's context lists what its value objects wrap ([§7](openapi.md)). +- **EF Core** is experimental under Native AOT and needs a compiled model and precompiled queries; + its `DbContext` constructors require dynamic code. With the satellite, a value object maps, writes, + queries by `.Value`, binds as a parameter and reads a stored value without validation, in the + native binary. EF limitations met on the way, not ours to fix: + - the compiled model cannot write the sentinel of a **struct value object wrapping a reference + type** (`default(Code)` converts to `null`, and EF then needs a literal for the struct itself). + Such a value object on an entity is a `sealed partial record` instead; + - EF 10's precompiled queries cast an entity to an internal interface, which does not compile for + a **sealed** entity class. +- **Third-party packages** report their own warnings (NodaTime.Serialization.SystemTextJson 1.4.0, + EF Core). The smoke test asserts only that none names a CodoMetis package or a woven value object. diff --git a/design/openapi.md b/design/openapi.md new file mode 100644 index 0000000..8a7c763 --- /dev/null +++ b/design/openapi.md @@ -0,0 +1,52 @@ +# OpenAPI + +**Decided 2026-09-27, measured in [spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)** +with Microsoft.AspNetCore.OpenApi 10.0.12. An earlier measurement had found that a schema +transformer never reached a type that only appears as a property, and repaired such types in a +document transformer. It does not hold in 10.0.12: one schema transformer reaches every JSON +position, and the change lands in the hoisted component. + +- **The schema is the wrapped type's, as ASP.NET publishes it for this host**, asked of ASP.NET + through `context.GetOrCreateSchemaAsync(typeof(T))`. It therefore follows the host's JSON options + (number handling, enum converter) exactly as the generated converter does, and the document's + other transformers (a NodaTime transformer describes a value object wrapping `Instant` too). No + format table of our own. +- **Keywords are filled in, never the object replaced**: the transformer receives the instance ASP.NET + hoists into the value object's own component, so `OrderId` stays a named component. What the + value object's schema already says is kept. ASP.NET's `Metadata` (the reference id) and the + schema-identity keywords are never copied; copying `Metadata` made every use a reference to the + wrapped type's component. A keyword is assigned only when the wrapped schema has one. +- **Containers**: ASP.NET drops `items`/`additionalProperties` for a converter-backed element before + any transformer runs; the value object's own schema is put back, only where missing. +- **Parameters** bound through the generated `TryParse` reach the transformer as `string`, ASP.NET's + placeholder for any parsable type, which the wrapped type's schema replaces. Minimal APIs name the + value object in the parameter's `Type`, MVC in `ModelMetadata.ModelType`; the parameter descriptor + names the container for an MVC `[FromQuery]` object and is not used. Each parameter then publishes + exactly what a parameter of the wrapped type publishes. +- **A value object that reaches itself** through what it wraps is an `InvalidOperationException` + naming the chain, not a stack overflow. +- **Source-generated JSON** ([§11](native-aot.md)): ASP.NET builds the wrapped type's schema from the host's JSON + contract for it, and a source-generated context has none for a type the host never serializes. + The transformer then refuses with an `InvalidOperationException` naming the value object and the + `[JsonSerializable(typeof(Guid))]` to add, rather than the serializer's message about a Guid. A + resolver of our own cannot be added: the host's options are read-only by then. +- **Component names (decided 2026-09-28, [decision 24](adr/0024-nested-value-objects-named-after-nesting.md)).** ASP.NET names a component after the type's + simple name, so `Shop.Id` and `Stock.Id` shared one, and a stock id wrapping an `int` was documented + as a uuid. `AddTypeKit()` wraps `CreateSchemaReferenceId`: a nested value object that got ASP.NET's + default name is named after its nesting chain (`ShopId`, as its companion class is `ShopIdExtensions`); + a name the host's own delegate chose, or `null` for an inlined value object, is kept. +- **Known gaps, documented rather than fixed:** a validation attribute on a value-object property + lands in the shared component, as ASP.NET does for any referenced schema (rules belong in `Create`); + a property absent from a request body stays `default`, since System.Text.Json calls no converter + for it (`required`, `RespectRequiredConstructorParameters`); a minimal API answers a refused body + with an empty 400; MVC's own binding message quotes the input, as it does for a `Guid`. +- **ASP0020** ([decision 12](adr/0012-asp0020-documented-suppressed.md)): a minimal-API route parameter whose value object is declared in the + same project fails the build, because the route analyzer reads the source before Metalama weaves + `IParsable` in. Binding is correct at run time and in the request delegate generator. A + `DiagnosticSuppressor` has no effect under Metalama's compiler, and Metalama's own suppression is + scoped to the aspect's targets, so the package README documents the pragma instead. + +Vogen 8.0.7 generates a schema transformer for Microsoft.AspNetCore.OpenApi too, from a table of +wrapped types (checked in its source at the tag, 2026-09-28; the 2026-09-27 note here said +Swashbuckle only). The README describes what the package does and claims nothing about other +libraries. diff --git a/design/option-result.md b/design/option-result.md new file mode 100644 index 0000000..a05f937 --- /dev/null +++ b/design/option-result.md @@ -0,0 +1,123 @@ +# Option and Result + +`Result`, `Result` and `Option`, with the `Option.None()`, `Result.Success()` and +`Result.Error()` markers ([decision 19](adr/0019-concise-implicit.md)), ship in `CodoMetis.TypeKit`. The value-object contracts +depend on them: `IValidatedValue.Create` returns a `Result`, and the generated `TryFrom` returns an +`Option`. + +**Non-goals.** No Either, no Validation applicative, no effect or IO types, no higher-kinded type +emulation, no immutable collections. No `Try` type: it is `Result` under another name, +and an exception enters through `Result.Catch` (phase 13, [decision 43](adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md)). No `Unit`: `Result` +is the value-less shape. Anything that turns this into a second LanguageExt is out. + +**Shape, decided 2026-09-27.** +- **`Result`, value first.** Error-first (`Either e a`) exists in Haskell and Scala + only because a generic type there can be filled in from the left alone, which C# cannot do. + Rust, F#, Swift, DotNext, CSharpFunctionalExtensions and the csharplang unions design note all + put the value first. `IValidatedValue` does too. +- **`readonly record struct`, non-positional, private fields.** The synthesized equality is + correct (`State` included). The synthesized `ToString` prints only public members: + `Option { }` and `Result { State = Error }`. So it never prints a value, in line with + `FromKnownGood`'s message. **Never make these types positional:** positional parameters become public + properties, which reintroduces `.Value` and puts the value into `ToString`. +- **The markers** ([decision 19](adr/0019-concise-implicit.md)): `Option.None()`, `Result.Success()`, `Result.Success(x)` and + `Result.Error(e)` return a small struct that converts implicitly to whatever `Option`/`Result` + the target is, so a method returns one without spelling out type arguments. They keep their + content internal too, so they print `Success { }` and `Error { }`. Only the implicit conversions + read it. Both shapes accept both result markers: `Result` converts from `Result.Error(e)` + as well as from a bare error, so a method can `return Result.Error(fault);` whichever shape it + returns. No combinator takes a marker-returning lambda: `Bind(x => Result.Success(x))` was `Map` + under another name, and is a compile error now. +- **`Result` converts from a bare value, never from a bare error** (measured + 2026-09-27). With both conversions, `Result r = 5;` compiles and is an **error**: + `int` is the more specific source type, so C# picks the error conversion. The READMEs had shown + `return Fault.X;` in valued methods, which does not compile; the consumer smoke test found it. +- **`Option.Some(null)` throws.** `notnull` is an annotation the runtime does not enforce, and a + `Some` over null reported a value it could not hand out. `Map` and the zips go through `Some`, + so a selector that returns null throws too. +- **So do `Result`'s factories.** Both shapes and both markers constrain their content to + `notnull`, and `Success(null)`/`Error(null)`/`Result.Success(null)`/`Result.Error(null)` throw + `ArgumentNullException`: a result over null handed it out of `TryGetValue`/`TryGetError` despite + `[NotNullWhen]`. `Map`, `MapError`, `Bind` and the conversions go through them. +- **Every delegate is checked before a branch is picked**, in `Option`, both `Result` shapes and + their extensions: a null for the branch not taken passed until the other outcome first arrived. + `NullDelegateTests` holds every delegate parameter of the assembly to a case run on both branches. +- **`Result.Match` hands the error to its error branch.** The only overload took a + parameterless `onError`, so `TryGetError` was the only way to the error. The parameterless one + stays, for a branch that does not need it. +- **`[DebuggerDisplay]`** on `Option` and both `Result` shapes shows the content in the debugger, + where it is what someone stepping through wants to see. The debugger is not a log. +- **`Result` has no `AsEnumerable`.** A sequence of zero or one units says no more than + the `bool` conversion. +- **Pipelines (decided 2026-09-28).** `MapAsync`/`BindAsync` take an asynchronous selector on a + result, and every combinator of both shapes continues a `Task>` (`MapAsync`, `BindAsync`, + `MapErrorAsync`, `TapAsync`), so a chain is awaited once, at its end; a completeness test holds + every combinator to a continuation. `Match` ends a chain after that `await`. Each continuation + checks its delegate before awaiting, so a null one is reported even when the pending result fails. + `Zip` combines two to six results, `Sequence` and `Traverse` a sequence of them. All stop at the + first error, in argument or sequence order: collecting errors is the validation applicative the + non-goals exclude. A zip still inspects every argument, so an uninitialized one after an error + throws. Callbacks return `Task` ([decision 30](adr/0030-asynchronous-callbacks-return-task.md)): `TapAsync` and `TapErrorAsync` take a + `Func<…, Task>` beside the `Action`, so an `async` lambda is awaited; a `ValueTask` one needs + `.AsTask()`. + +**Decided 2026-09-27: an uninitialized `Result` throws.** +- A `default` result is `State == Uninitialized`. If the branching members tested only + `State == Success`, it would take the **error** branch with `default(TError)`. +- For an enum fault that is the first member (e.g. `Blank`): a plausible, wrong reason. For a + reference-type error it is `null`, despite `[NotNullWhen(false)]`. +- The analyzer blocks `default` in source, but array elements, class fields (CMTK0005/0006) and + reflection still produce such instances. +- **Decision:** every member that picks a branch (`Match`, `Map`, `Bind`, `Tap`, `TapAsync`, + `TapErrorAsync`, `TryGetValue`/`TryGetError`, `AsEnumerable`, the `bool` conversion of + `Result`) throws + `InvalidOperationException` on `Uninitialized`. That is a loud failure instead of a fabricated + fault. `State`, equality and `ToString` stay safe to call. + +**Decided 2026-09-27: `Option`/`Result` are not wire types.** +- Measured: with private state and no converter, System.Text.Json wrote `{}` for a `Some` and read + it back as `None`, and wrote `{"State":1}` for a success and read it back as `Uninitialized`. + Nothing raised. +- **Decision:** `NotWireTypeJsonConverterFactory`, on `Option`, both `Result` shapes and the + `Success`/`Success`/`Error` markers, throws `NotSupportedException` from `Read` and `Write`, + naming the type and the alternative and never the content. `HandleNull` is on, so a JSON `null` + gets the same message, and a dictionary key does too. That is what the serializer itself does for + `System.Type`. It hands every type one converter typed `object`, which the serializer wraps: a + converter per type needed `MakeGenericType`, and under Native AOT that failed for these structs + with the runtime's message instead of this one ([§11](native-aot.md)). +- A serialized shape says absent with `T?`; `ToOption()`/`OrNull()` convert at the boundary; a + result is matched to a response or a document. An application that wants a wire format registers + its own converter on its options, which takes precedence over the type's attribute, in a + source-generated context too (measured). +- **Not a lossless converter** (`Some(x)` as `x`, `None` as `null`): its bytes are identical to + `T?`, so it would only move the CLR type onto the shape the design keeps it off, and a `Result` + has no sensible wire shape at all. +- **Not an analyzer:** whether a type is serialized is a property of the application's roots and + options, which the package cannot see. An application can add a shape test of its own. +- **Known gap:** the serializer calls no converter for a property that is absent from the document, + so it stays `default`. `required` or `RespectRequiredConstructorParameters` closes it, and the + README says so. +- Completeness: `NotWireTypeTests` walks every exported struct of the assembly, so a struct added + later refuses too, or is exempted on purpose. + +**Why not an existing package** (checked 2026-09-26). The criteria: no public `.Value`/`.Error`, +a generic `TError`, `default` is not success, maintained. +- **Funcky 3.6.0** is closest (`[NonDefaultable]` enforced by an analyzer, no `.Value`), but its + `Result` fixes the error type to `Exception`. +- **nlkl/Optional** has the right Option shape but no Result, and no release since 2018. +- **CSharpFunctionalExtensions, DotNext and FluentResults** expose a throwing `.Value`. +- **ErrorOr, Remora.Results and Ardalis.Result** expose an unguarded one. With ErrorOr, + CSharpFunctionalExtensions and Remora, `default(Result<…>)` reads as success. +- **JSON** (checked 2026-09-27): CSharpFunctionalExtensions 3.7.0 ships opt-in converters that write + a `Result` as a DTO with `IsSuccess`/`Error`/`Value`, for passing outcomes between services; + LanguageExt (v5 still beta) ships none; ErrorOr, OneOf and FluentResults ship none and serialize + their public properties as they are. None refuses. + +C# 15 `union` types (in .NET 11 preview) ship no standard `Option`/`Result`, and they need .NET 11 +anyway, while this repo targets net10.0. + +**Against the value-object libraries** (checked 2026-09-27): +- Vogen validation returns Ok/Invalid with a plain string, and its `TryFrom` is `bool` + `out`. +- Thinktecture returns a typed `ValidationError` but ships no Result type. +- TypeKit's value objects return `Result` from `Create` and `Option` from + `TryFrom`. That is the reason Option/Result and the value objects ship together. diff --git a/design/packages.md b/design/packages.md new file mode 100644 index 0000000..14f2541 --- /dev/null +++ b/design/packages.md @@ -0,0 +1,34 @@ +# Packages + +| Package | Depends on | Metalama | Contents | +|---|---|---|---| +| `CodoMetis.TypeKit` | Analyzers (flows, [§10](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([§9](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([§4](ef-core.md)); `RequireCustomInitializationAttribute`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([§3](discovery.md), [§5](aspects.md), [§11](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `IValueObjectVisitor<>`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource` | +| `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes ([§10](analyzers.md)). Nobody references it directly: it reaches every consumer through the base package | +| `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects, internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | +| `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` | +| `CodoMetis.TypeKit.AspNetCore` | TypeKit, Microsoft.AspNetCore.OpenApi | no | `AddTypeKit()`: one OpenAPI schema transformer ([§7](openapi.md)); `ToHttpResult()`, `AddFaultResponses` ([decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)) | +| `CodoMetis.TypeKit.Serilog` | TypeKit, Serilog 4.0.0 | no | `Destructure.WithTypeKit()`: one destructuring policy that logs every value object as its `ToString()` ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)) | +| `CodoMetis.TypeKit.Testing` | TypeKit | no | `ShouldBeSome()`/`ShouldBeNone()`, `ShouldBeSuccess()`/`ShouldBeError()`: test assertions that hand back the content, with no test framework dependency ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)) | + +**The bare name is the cheapest package.** Someone who only wants `Option`/`Result` references +`CodoMetis.TypeKit` and never receives Metalama by accident. Taking Metalama on is always an +explicit, named choice (`.Generators`). The EF and ASP.NET packages depend on the base package, +not on `.Generators`. They work at run time against the interfaces, so a host that maps value +objects from a referenced domain assembly never needs Metalama itself. + +**Namespaces:** + +| Namespace | Holds | +|---|---| +| `CodoMetis.TypeKit` | `Option`, `Result`, `ResultState`, the markers, `NotWireTypeJsonConverterFactory`, `RequireCustomInitializationAttribute`, `GuidValueExtensions` (`New()` is call-site vocabulary, like `Option`) | +| `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | +| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `IValueObjectVisitor`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | +| `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`; the plugins, internal | +| `CodoMetis.TypeKit.AspNetCore` | the schema transformer, internal; `FaultProblem` | +| `CodoMetis.TypeKit.Serilog` | the destructuring policy, internal | +| `Serilog` | `WithTypeKit()`, beside Serilog's own configuration methods ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `CodoMetis.TypeKit.Testing` | the assertions and `TypeKitAssertionException` | +| `Microsoft.EntityFrameworkCore` | `UseTypeKit()`, beside the providers' `UseNpgsql` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `Microsoft.Extensions.DependencyInjection` | `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()`, beside `AddEntityFrameworkNpgsql` and `AddOpenApi` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | + +The internal aspects live in `CodoMetis.TypeKit.Generators`. Users never type that namespace. diff --git a/design/plan.md b/design/plan.md index 66f4de9..bc72c43 100644 --- a/design/plan.md +++ b/design/plan.md @@ -1,1916 +1,25 @@ -# Plan: CodoMetis.TypeKit - -Status: **1.0.0 released 2026-09-28** (nuget.org, GitHub release `v1.0.0`). Phases 0 to 11 are done; -what is left for 1.x is listed at the end of phase 11, and package validation holds every pack to the -1.5.0 surface. Phase 12 is 1.5.0, released 2026-10-11; 1.5.1 followed the same day with a fix to parameter schemas. Phase 13 is the next release, 2.0.0 (decisions 42 and 44: `net10.0`, Metalama 2026.1). The decisions are in §8, Native AOT in §11. The fabric spike -([spikes/FabricSpike](../spikes/FabricSpike/README.md)), the translation comparison -([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)), the EF mapping spike -([spikes/EfMapping](../spikes/EfMapping/README.md)) and the OpenAPI spike -([spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)) are done, and so is phase 12's -`Result`-to-HTTP spike ([spikes/ResultHttp](../spikes/ResultHttp/README.md)). - -## 1. Packages - -| Package | Depends on | Metalama | Contents | -|---|---|---|---| -| `CodoMetis.TypeKit` | Analyzers (flows, §10) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` (§9); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` (§4); `RequireCustomInitializationAttribute`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use (§3, §5, §11): `GeneratedValueObjectAttribute<,>`, `IValueObjectVisitor<>`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource` | -| `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes (§10). Nobody references it directly: it reaches every consumer through the base package | -| `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects, internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | -| `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` | -| `CodoMetis.TypeKit.AspNetCore` | TypeKit, Microsoft.AspNetCore.OpenApi | no | `AddTypeKit()`: one OpenAPI schema transformer (§7); `ToHttpResult()`, `AddFaultResponses` (decision 38) | -| `CodoMetis.TypeKit.Serilog` | TypeKit, Serilog 4.0.0 | no | `Destructure.WithTypeKit()`: one destructuring policy that logs every value object as its `ToString()` (decision 41) | -| `CodoMetis.TypeKit.Testing` | TypeKit | no | `ShouldBeSome()`/`ShouldBeNone()`, `ShouldBeSuccess()`/`ShouldBeError()`: test assertions that hand back the content, with no test framework dependency (decision 40) | - -**The bare name is the cheapest package.** Someone who only wants `Option`/`Result` references -`CodoMetis.TypeKit` and never receives Metalama by accident. Taking Metalama on is always an -explicit, named choice (`.Generators`). The EF and ASP.NET packages depend on the base package, -not on `.Generators`. They work at run time against the interfaces, so a host that maps value -objects from a referenced domain assembly never needs Metalama itself. - -**Namespaces:** - -| Namespace | Holds | -|---|---| -| `CodoMetis.TypeKit` | `Option`, `Result`, `ResultState`, the markers, `NotWireTypeJsonConverterFactory`, `RequireCustomInitializationAttribute`, `GuidValueExtensions` (`New()` is call-site vocabulary, like `Option`) | -| `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | -| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `IValueObjectVisitor`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | -| `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`; the plugins, internal | -| `CodoMetis.TypeKit.AspNetCore` | the schema transformer, internal; `FaultProblem` | -| `CodoMetis.TypeKit.Serilog` | the destructuring policy, internal | -| `Serilog` | `WithTypeKit()`, beside Serilog's own configuration methods (decision 22) | -| `CodoMetis.TypeKit.Testing` | the assertions and `TypeKitAssertionException` | -| `Microsoft.EntityFrameworkCore` | `UseTypeKit()`, beside the providers' `UseNpgsql` (decision 22) | -| `Microsoft.Extensions.DependencyInjection` | `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()`, beside `AddEntityFrameworkNpgsql` and `AddOpenApi` (decision 22) | - -The internal aspects live in `CodoMetis.TypeKit.Generators`. Users never type that namespace. - -## 2. Phases - -Each phase ends green, and its guards have been proven by seeding the defect -(`.claude/skills/verify-the-guard`). - -0. **Repo skeleton. ✅ Done 2026-09-27.** - - `Directory.Build.props`: warnings as errors, CS1591, Source Link, and package validation - without a baseline until the first release. - - `Directory.Packages.props` (central management; `spikes/` opts out), and `global.json` - pinned to 10.0.401 / `latestPatch`. - - The five shipping projects, still empty, in `CodoMetis.TypeKit.slnx`. - - The analyzer targets netstandard2.0 on Roslyn 5.0.0 (the floor), with RS2008 release tracking. - - `.github/workflows/dotnet.yml` (build and test, SHA-pinned actions, SDK from global.json) and - `dependabot.yml`. - - First guard: `MetalamaBoundaryTests`. Only `.Generators` may resolve Metalama, directly or - transitively. Proven by seeding a direct reference in the base package, which failed the base - and both satellites, and a transitive one via the EF satellite, which failed only that - satellite. -1. **Option and Result. ✅ Done 2026-09-27.** - - `Option`, `Result`, `Result`, the `Result.Success`/`Result.Error` markers, - their extensions and `RequireCustomInitializationAttribute` in `CodoMetis.TypeKit`. Tests in - `test/CodoMetis.TypeKit.Tests`. - - The throw on `Uninitialized`, a `ToString` that never prints the content, and a - `[DebuggerDisplay]` that does (§9). - - Guards, each proven by seeding its defect: - - one throw case per branching member (21), plus a completeness test that fails when a - public `Result` member is added without being classified as branching or not; - - `ToString` never contains the content, including for the markers; - - the debugger display names an existing private property and shows the content; - - the collapsing `Match` runs its success callback. - - Found in the fourth review on 2026-09-27, and fixed: `Option`/`Result` serialized as `{}` and - `{"State":1}` and read back as `None` and uninitialized, silently. Every exported struct now - carries `NotWireTypeJsonConverterFactory` (§9). Guard: with the attributes removed, 58 of the 62 - `NotWireTypeTests` fail, the other 4 pin the escape hatch and the absent-property gap. - - Found in the fifth review on 2026-09-27, and fixed: `Result` never checked its delegates, so a - null for the branch not taken passed until the other outcome first arrived (`Match(null, …)` on - every error), and `Option.ToResult` and the zips lost `Option`'s own check inside a lambda. Every - delegate is now checked before a branch is picked (§9). Guard: `NullDelegateTests` runs each case - on both branches, and its completeness test holds every delegate parameter of the assembly to a - case; with the checks reverted, 29 of its 42 tests fail. -2. **Value-object contracts and analyzer. ✅ Done 2026-09-27.** - - The contracts: `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<>`, `IValidatedValue<,,>`, - `IValueObjectMaterializer<,>`, `GeneratedFactories`, `TranslatedAsWrappedValueAttribute`, and - `GuidValueExtensions` with `New()` and `New(DateTimeOffset)`. No NodaTime. - - CMTK0001 and CMTK0002, resolved by symbol once per compilation (§3). CMTK0001 also follows a - type parameter's constraints (`default(T)`/`new T()` where `T : struct, IValue<…>`, or - `T : IValue<…>, new()`, which only a struct value object satisfies). - - The verifier tests compile against the real `CodoMetis.TypeKit` assembly, with no copy of the - contracts, and `default(Option)` is one of the positive controls. - - Packaging (§10): `CodoMetis.TypeKit` depends on `CodoMetis.TypeKit.Analyzers`, which ships only - `analyzers/dotnet/cs`. `AnalyzerPackagingTests` packs both and reads the result. - - Guards, each proven by seeding its defect: - - a drifted metadata name silences the positive controls (10 tests for `IValue`1`, 9 for the - attribute); - - losing indirect markers (2) or type-parameter constraints (2) fails their tests; - - matching the generators by prefix or by suffix fails the look-alike cases (1 each); - - dropping the dependency, excluding its analyzers, packing the analyzer into `lib/` or - marking it a development dependency each fail a packaging test. -3. **Generators. ✅ Done 2026-09-27.** - - The ten aspects, the transitive fabric and `AspectOrder` in `CodoMetis.TypeKit.Generators`, - built on Metalama 2026.1.28 (§5). - - Every generated way into a validated value object applies `Create` (§5, entry points). - - Probes in `test/CodoMetis.TypeKit.Generators.Probes`, one per JSON and parsing strategy plus - the edge cases, and behaviour tests for every generated member against them. The tests reach - the generators only through the probes, which is the transitive-fabric check. - - The generated-surface snapshot (§6), `GeneratedSurface.verified.txt`. - - Build-outcome tests: a throwaway consumer with declarations that cannot be generated gets - exactly CMTK1000–1009 and CMTK0001, nothing else, and no CMTK0002 beside the real generators. - - Guards, each proven by seeding its defect: - - a JSON read, `Parse` or `TryParse` that bypasses `Create` fails 7, 9 and 5 entry-point - cases; a new public factory without a case fails the completeness tests; - - `MinValue` on a validated number, a missing JSON-null or `Parse(null)` guard, and a fabric - that misses indirect markers or nested types each fail their tests, and the surface ones - the snapshot too; - - without the more-than-one-marker or the generic-type check, the build-outcome tests see an - aspect failure instead of CMTK1003/1005; - - an analyzer that stops seeing value objects fails the CMTK0001 build test, and one that - looks for a misspelled generators assembly breaks the probes build with CMTK0002. - - Found while building them, and fixed: JSON reads and parsing constructed validated value objects - without `Create`; `MinValue`/`MaxValue` wrapped an unvalidated bound; a JSON `null` or - `Parse(null)` wrapped null; `bool`/`char` (explicit `IParsable`) did not compile; a NodaTime - `LocalDate` could not be a dictionary key; an internal value object got a public extension - class; a marker reached through a derived interface would not have been generated. - - Found in review on 2026-09-27, and fixed with guards proven by reverting: an enum-backed value - object's parsing threw for every input; string ordering was culture-sensitive while equality - is ordinal; `Parse(x.ToString(), null)` did not round-trip outside the invariant culture; - `IValue` failed as an aspect bug instead of CMTK1005; a fallback JSON key did not read - back; a record class's comparison threw on null (§5); `Option.Some(null)` reported a value; - and `Result` did not accept the `Result.Error(e)` marker (§9). - - Found in the second review on 2026-09-27, and fixed the same way (39 guards fail with every fix - reverted): two nested value objects of one name crashed Metalama, and a declared - `{Name}Extensions` failed the aspect (CMTK1007 now); malformed JSON threw `FormatException`, - which a minimal API answers with 500; interpolation formatted in the current culture, so - `Parse($"{x}", null)` read "1,5" as 15; a `DateTime` without an offset was converted through - the server's time zone; `From(null)` wrapped null; `Result` held a null value or error; - `Result.Match` could not see the error (§9); a derived record-class value object failed - inside the generated code (CMTK1006 now); CMTK0001 missed `new T()` under `new()`. - - Found in the third review on 2026-09-27: a hand-written comparison operator failed the aspect - (LAMA0500) and a hand-written object `CompareTo` was kept silently beside the generated generic - one; both are CMTK1008 now, and the one seam, `CompareTo(TSelf)`, is documented and pinned (§5). - - Found by the OpenAPI spike on 2026-09-27 (finding 16): a record class that wraps itself, or - two that wrap each other, compiled without a word, and a struct cycle failed as LAMA0611. Every - value object over a value object is CMTK1005 now (§5). Guards, each proven by seeding its - defect: without the refusal 12 build-outcome tests fail (the struct cycle's LAMA0611 among - them); reporting a cycle as plain nesting fails 4 (a self-wrap then compiles again); allowing - nesting without a cycle fails 2. - - Found in the fifth review on 2026-09-27: a hand-written constructor. One with the generated - constructor's signature failed as LAMA0611, a positional record (`OrderId(Guid Value)`) as - LAMA0500 or an exception in the aspect (LAMA0041), and any other compiled without a word: it - skipped `Create`, left `Value` null, or, as a record's copy constructor, did that to `with`. - Every hand-written instance constructor is CMTK1009 now (§5). Guards, each proven by seeding its - defect: without the refusal 13 build-outcome tests fail, the LAMA errors among them; refusing a - static constructor too fails 2. -4. **EF Core. ✅ Done 2026-09-27.** - - The mapping spike first (spikes/EfMapping): an additive type-mapping-source plugin maps every - value object, with keys, foreign keys, nullable properties and primitive collections, on - SQLite and PostgreSQL, whatever the registration order, and beside a library that replaces - EF's converter selector (decision 9). - - `CodoMetis.TypeKit.EntityFrameworkCore`: `ValueObjectConverter`, the plugin, the - `.Value`/`GetValue()`/`ValueOrNull()` translators, `UseTypeKit()`, and - `AddEntityFrameworkTypeKit()` for an application that builds EF's internal service provider. - - Stored JSON (§4): `StoredJsonConverterFactory` in the base package (decision 10). - - Tests: the model on SQLite, SQL snapshots on PostgreSQL via `ToQueryString`, the default - comparer against `.Value`, and one Testcontainers PostgreSQL round trip covering every - wrapped-type family, stored values the rules refuse, and the translated queries. - - Guards, each proven by seeding its defect: a plugin that stops recognising value objects - fails 33 of 34 EF tests (all but the control), one that drops the column facets fails the - facet test; losing the member or the method translator - fails 4 and 3; a validating `Materialize` fails the stored-row test; a stored-JSON converter - that validates fails the stored value and key tests. The SQL snapshots first failed on the - cast `Convert` produced, which led to re-typing the column (§4). - - Found in the fifth review on 2026-09-27, and fixed: the method translator took any static - one-argument method carrying the public `TranslatedAsWrappedValueAttribute` for the unwrap, so - a hand-marked `Length(this ProbeCode)` became `WHERE o."Code" = '3'`. It now requires the - unwrap's signature (§4). Guard: the hand-marked test fails without the check, and the - `GetValue`/`ValueOrNull` snapshots, now with a record class's `ValueOrNull`, still translate. -5. **ASP.NET Core. ✅ Done 2026-09-27.** - - The measurement spike first (spikes/OpenApiSchemas): a schema transformer reaches a value - object that only ever appears as a property, which the earlier measurement had denied; container - elements and `TryParse`-bound parameters need handling of their own (§7, decision 11). - - `CodoMetis.TypeKit.AspNetCore`: `OpenApiOptions.AddTypeKit()`, which adds - `ValueObjectSchemaTransformer`. A value object's schema is filled in, keyword by keyword, from - the schema ASP.NET itself publishes for the wrapped type. - - Tests in `test/CodoMetis.TypeKit.AspNetCore.Tests`: a probe host with minimal APIs and MVC whose - document is read per shape (15 value-object properties across the wrapped-type families, nullable, list, array, set, dictionary, - nested containers, bodies, route/query/header/`[AsParameters]`/MVC `[FromQuery]` parameters, a - query array), each against a control that uses the wrapped type, in OpenAPI 3.1 and 3.0; the - host's enum converter and number handling, on the wire too; composition with another - transformer in either order; a host that inlines value objects; a keyword-completeness test over - every `OpenApiSchema` property. - - Guards, each proven by seeding its defect: not registering it fails 66 tests; dropping the - container branch 9, the parameter branch 18, the MVC model-metadata fallback 8; using the - parameter descriptor instead fails the MVC `[FromQuery]` object (4); keeping ASP.NET's `string` - placeholder fails the integer parameters (8); dropping the nullable unwrap 9; copying - `Metadata` 44; overwriting what a container or a keyword already had 1 and 28; sharing - collections 5; assigning a missing keyword (null) 174; without the recursion stop every - document is refused (73); without the cycle refusal the test host dies of a stack overflow. - - Found while building it: assigning `null` to `OpenApiSchema.Const` is not a no-op, it writes - `"const": null`, so every keyword is assigned only when the wrapped schema has one, and the - keyword tests compare the written schema, not the getters. - - Found in the spike and documented, not fixed: ASP0020 (an error) for a minimal-API route - parameter whose value object is declared in the same project (decision 12). -6. **Delivery. ✅ Done 2026-09-27**, up to the first tag, which needs the GitHub repository and the - nuget.org policy (release.yml, "Setup, once"). - - Per-package READMEs: `src//README.md`, packed and named by `PackageReadmeFile` from - `src/Directory.Build.props`, so a new package cannot pack without one. `PackageReadmeTests` - packs every shipping project and reads the nuspec and the package. - - `test/consumer-smoke-test.sh [feed]`: packs the five packages (or takes a packed feed) and - builds three consumers outside the repository, with a private `NUGET_PACKAGES`, package source - mapping and the repository's `global.json`. `core` references only `CodoMetis.TypeKit`: Option - and Result work, no Metalama arrives, CMTK0001 and CMTK0002 fire. `layered`: a domain library - on the generators and an app that reaches them only through it; the app uses the generated - members, the transitive fabric generates a value object the app declares, and CMTK0001 fires in - the app with no CMTK0002. `host`: both satellites in a running web host; the OpenAPI document, - route binding, EF mapping, a round trip and the `.Value` SQL. It asserts on output, diagnostics - and the resolved package graph, never on an exit code alone. - - CI (`dotnet.yml`): test, then a pack job (smoke test against the packed feed, SBOMs) and a - smoke-test job that packs its own feed, the two modes a sibling repository found not to be - interchangeable. - - `release.yml`: on a `v*` tag, verify (tag against `Version`, the changelog dates the version, - full suite, pack, smoke test against the packed feed, one CycloneDX SBOM per package), then - publish through Trusted Publishing behind the `nuget` environment's approval, then a GitHub - release with the changelog section as notes and the SBOMs attached. Only the publish job can - mint a token, and it cannot write to the repository. `CHANGELOG.md` and a release checklist in - CONTRIBUTING.md. - - SBOMs exclude the analyzer's `PrivateAssets=all` Roslyn references: unfiltered, the analyzer's - SBOM listed 13 components for a nuspec that declares none (measured with CycloneDX 6.2.0). - - The unlicensed build: fabric spike finding 9 confirmed in a clean container, no license file - and no variable, the whole solution in Release and all 254 generator tests green. - - Guards, each proven by seeding its defect: the smoke test fails when the base package loses its - analyzer dependency (and the `default(Option)` build then succeeds) and when `AddTypeKit()` - does nothing (three OpenAPI assertions); a generators package without its assembly fails at - pack (NU5128), and a non-flowing Metalama in the repository's own build. - `ReleaseWiringTests`, `ChangelogTests` and `PackageDependencyTests` (17 seeds, each failing its - test): a renamed workflow or environment, a token in the verify job, repository write in the - publish job, a dropped tag or changelog check, either smoke-test mode dropped, a non-executable - script, a narrowed SBOM filter, an unpinned tool, a second publishing path, a malformed or - missing changelog section, a dated version with unshipped rules, a satellite without the base - package, a satellite depending on the generators. - - Found by the clean container: the build-outcome tests built their consumer in Debug whatever the - run's configuration, so CI's Release run would have failed 17 of them; the consumer now builds - in the test assembly's own configuration. - - Found by the smoke test: every README returned a bare fault from a method typed - `Result`, which does not compile (§9); and a host that has - `Microsoft.AspNetCore.OpenApi` only through the satellite fails with CS9137, because that - package enables its generator's interceptors in `build/`, which NuGet imports for a direct - reference only. The satellite README says to reference it, as the `webapi` template does; a copy - of Microsoft's switch in our own `buildTransitive` would drift from theirs. - -7. **Native AOT. ✅ Done 2026-09-27** (§11). - - `IsAotCompatible` on the three run-time packages, so the trim and AOT analyzers run in every - build and a warning fails it. `AotCompatibilityTests` reads the packed assemblies' metadata; the - compile-time packages are exempt by name, with the reason. - - The consumer smoke test's `aot` consumer: a domain library and a web host with JSON, OpenAPI - and EF Core (compiled model, precompiled queries), published with `PublishAot`. No trim or AOT - warning may name a package or a woven value object, and the native binary is run and asserted - on. The workflows install clang and zlib before it, held by a wiring test. - - Found by publishing with Native AOT, each fixed and guarded: - - `StoredJsonConverterFactory` found its private constructor by reflection, which the trimmer - removed: stored JSON was **validated after all**, silently. It now asks the generated - converter through `IStoredJsonConverterSource`. - - the OpenAPI satellite described **every value object as `{}`**, silently: the trimmer removes - `IValueObject<,>` from a type's interfaces when nothing uses it, and `GetInterfaces()` found - nothing. The satellites now read `GeneratedValueObjectAttribute<,>` (decision 17). - - `Option`/`Result` refused JSON with the runtime's "missing native code" message instead of - their own: the refusing converter was made with `MakeGenericType`. One object-typed converter - now serves every type. - - the generated JSON converter serialized the wrapped value through the options (20 warnings in - the consumer's assembly), and a source-generated context without the wrapped type refused a - `decimal` value object outright, on the JIT too. It now goes through `GeneratedJson.TypeInfo`. - - the generated parsing of a `[TypeConverter]` type (NodaTime) called - `TypeDescriptor.GetConverter`, which requires unreferenced code. - - EF Core: the converter's read path called an internal helper, which a compiled model - (`dotnet ef dbcontext optimize`) writes out as C# in the application's assembly, where it does - not compile; a precompiled query cast the converter to its design-time type, which the - compiled model does not recreate; a value-object parameter, mapped at run time, hit EF's own - `MakeGenericType` guard composing its JSON reader/writer; and the converter was made with - `MakeGenericType`. - - Guards, each proven by seeding its defect: reverting the generated JSON to the options fails 6 - `SourceGeneratedJsonTests`, and in the smoke test's `aot` consumer the warnings assertion (8 woven - warnings) and the JSON assertions; each reflection or `MakeGenericType` call put back fails the - build under the analyzers, and suppressed with `#pragma` only, the smoke test's warnings - assertion (IL2070 from ILCompiler); without the generated attribute, the OpenAPI host under - Native AOT is `{}` again; dropping `IsAotCompatible` from a package fails - `AotCompatibilityTests` for that package; a missing or late toolchain step fails the wiring test. - - Not caught at run time: a `GetInterfaces()` lookup suppressed with - `[UnconditionalSuppressMessage]`, since the attribute keeps the interface alive (§11). -8. **Pre-release naming and syntax. ✅ Done 2026-09-27.** - - Names (decision 18): the wrapped value, the plain value object, the value object's own type - parameter and the delegate parameters each have one name, and the namespaces follow who uses - a type (§1). Every rename is a compile error for code that used the old name; the analyzer's - metadata-name tests and the generated-surface snapshot moved with them, the snapshot by exactly - the interface rename and nothing else. - - Syntax (decision 19): `Result.Success` (was `Result.Ok`), an `Option.None()` marker, `MapError`, - `Bind` from a valued result onto `Result`, `Or(fallback)` (was `Coalesce`), and a - `ToResult(error)` that keeps the value; the marker-lambda overloads and the collapsing `Match` - are gone. Measured first against a replica of the conversions: the marker works in a return, a - conditional beside `Some`, a `Bind` lambda and an `async` method, and the new `Bind` resolves - beside the generic one. The completeness tests forced a case for every new member; seeding - `None` out of the refusal, `MapError` past the uninitialized check, or the command into the - error branch of `Bind` fails 7, 1 and 1 tests. `ToResult` checks its error on either branch, - like the delegates, and a `Some` that accepted a null error fails its test. -9. **Before the first release: rule set, Result pipelines, performance. ✅ Done 2026-09-28.** - - The analyzer rule set (decision 13): CMTK0003–CMTK0008 beside CMTK0001/0002 (§10). CMTK0004 - ships at Error, which only the first release can do without a major version. - - `Revalidate()` on a validated value object (§5): `Create` applied to the value it holds, for - finding the stored values a rule added later refuses. - - `Result` (§9): `MapAsync`/`BindAsync` on both shapes, and on a `Task>` a continuation - of every combinator (`MapAsync`, `BindAsync`, `MapErrorAsync`, `TapAsync`); `Zip` for two to six - results; `Sequence` and `Traverse`. All stop at the first error. - - Benchmarks (`benchmarks/`), against the wrapped types, and a spike against Vogen - ([spikes/ValueObjectPerformance](../spikes/ValueObjectPerformance/README.md)). - - Found by the benchmarks, and fixed: `TryFormat` boxed the wrapped value (32 bytes, 3.5 times a - `Guid`'s time); comparing an enum-backed value object boxed both operands (sorting 1,000 took 8 - times as long and allocated 367 KB); the JSON converter wrote and read a number through a nested - serializer call (1.35–1.40 times). All at parity after the fix (§5). - - Found while measuring the rules on woven code: Metalama runs analyzers on the source before - weaving, where a call to a generated member of a value object in the same project does not bind, - so CMTK0003 missed an ignored `TryFrom` and CMTK0007 every `FromKnownGood` (§10). - - Guards, each proven by seeding its defect: the new `Result` members' completeness tests force a - case each (28 null-delegate cases, one uninitialized case per extension, one continuation per - combinator), and six seeds (zips short-circuiting, a delegate checked after the await, `Sequence` - enumerating past the error, `MapAsync` calling its selector on an error, a combinator without a - continuation, the second error first) fail 5, 16, 3, 2, 3 and 1 tests. Each rule's verifier tests - have a positive control per form and a negative control per exemption; 29 seeds across the six - rules each fail their test. A `Revalidate()` that skips `Create` fails both of its behaviour - tests, one of them against PostgreSQL. CMTK0004's exemption of EF's compiled model is measured by the smoke - test: with generated code analysed, the AOT consumer fails with six CMTK0004 errors in - `CompiledModels/OrderEntityType.cs`. The build-outcome consumer holds every rule to a value object - of its own project; without the unbound-call path CMTK0003 and CMTK0007 fail there. The - performance fixes are held by allocation tests on a Debug build, where the JIT never removes a - box, and a number-handling matrix; reverting any fix fails them. -10. **Release readiness for 1.0.0. ✅ Done 2026-09-28.** Four independent audits (the generators, the - satellites, the analyzers, the documentation), each confirming findings empirically in throwaway - consumers of packed packages, and the fixes below, each with a test that fails with the fix reverted. - - Release: the first release is 1.0.0 (decision 21); package tags, copyright and release notes, - held by `PackageReadmeTests`; the analyzer's Roslyn floor measured and held (decision 25), and the - build-time `Microsoft.CodeAnalysis.Analyzers` on 5.9.0; registration extensions in the framework - namespaces (decision 22), which the smoke test's consumers use without a `using`. - - `Option`/`Result`: `Ensure`, `TapError` and their continuations; query syntax with more than - one `from` (the one-selector `SelectMany` made the second `from` a compile error); - `GetValueOrNone`; `FirstOrNone()`/`LastOrNone()` without a predicate; `FirstOrError`/`LastOrError` - check their error on either branch, as `ToResult` does. The completeness tests forced a null and - an uninitialized case for each; six seeds fail seven tests. - - Generators (§5): a hand-written `ToString()` was replaced silently (a secret printed); a value - the wrapped type could not parse was quoted in the exception; the JSON differed from the wrapped - type's for `TimeOnly`, date keys, host converters and key policies; an enum-backed value object - allocated 4.7 KB per JSON value; `Uri` parsing disagreed with JSON; `ToType` failed the identity - conversion; nine declarations failed as Metalama errors (CMTK1005, CMTK1010, CMTK1011 now). Guards: - `ToStringSeamTests`, `UnreadableInputTests`, `JsonParityTests`, the allocation and parsing tests, - 18 build-outcome rows. - - Analyzers (§10): nothing fired in Razor; `OrDefault()` and `FirstOrDefault` handed out defaults - unreported (CMTK0009); false positives on guard checks and zero-length arrays; false negatives - in the declaring project, for `x!`/`(x)`/named arguments, static comparisons, and a `Task` of a - result converted to `Task`; CMTK0001 35% faster. 42 verifier tests and six build-outcome cases - added, every seed recorded failing. - - Satellites (§4, §7): `.Value` on a collection element failed to translate; a key over an integer - lost its identity column (decision 23); nested value objects shared an OpenAPI component - (decision 24). The smoke test's AOT consumer inserts through an identity key. - - Documentation: every C# block of the READMEs is compiled and its stated outputs asserted (§6, - item 6); four samples did not compile, one SQL comment was wrong, and a dozen claims were wrong - or untested (a JSON `null` refused by a record class, configuration binding under the source - generator, a JSON 400 with a message from a minimal API, "symbols for every package", …). Each - is now corrected, or backed by a test that did not exist. - - Found and documented, not fixed: a property absent from a JSON body stays `default` (System.Text.Json - calls no converter; `required`/`RespectRequiredConstructorParameters`, now in the ASP.NET README - with a test); `ConfigureConventions` for the wrapped type does not reach a value object; a - validation attribute on a value-object property lands in the shared OpenAPI component. - - Upstream: the Metalama issue behind CMTK1010, reproduced with Metalama.Framework 2026.1.28 and - 2027.0.3-preview by a five-line aspect that introduces `ToString` with `OverrideStrategy.Override` - into a `partial record struct` declared in two parts (LAMA0611 CS0111, then LAMA0613). Lift - CMTK1010 once Metalama emits the override once. - -11. **Second review, against the packed 1.0.0. ✅ Done 2026-09-28.** Five reviews (the generators, - `Option`/`Result`, the analyzers, the satellites, packaging and release), each building throwaway - consumers of the packed packages with a private package cache, and the fixes below, each with a - guard seeded to fail. - - Analyzers (§10): where Metalama compiles a project, source generators run after its - transformation (Metalama 2025.0), and an analyzer sees only the source. Every rule was silent in - the `.razor` files of the usual Blazor layout (a web project referencing a value-object project) - and missed forms in the declaring project. The analyzer package's `buildTransitive` props lists - the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer` item (decision 33). - `AnalyzerPackagingTests` holds every analyzer to it; the smoke test's layered consumer gains a - Razor library and a guard in Domain, and both build clean with the props left out. Every rule - has a `helpLinkUri` (`AnalyzerHelpLinkTests`). The README adds `[RequireCustomInitialization]` for - a struct made of value objects, and `.globalconfig` for severities in Razor, which no - `.editorconfig` section reaches (measured). - - `Option`/`Result` (§9): `Result` lost its `bool` conversion (decision 29); - `TapErrorAsync(Func)` on both shapes and both continuations, since an `async` error - callback ran as `async void` and its exception ended the process (decision 30); `TryCast` on - `object?` removed (decision 31). CMTK0003 treats the instance `TapErrorAsync` like `TapAsync`. - Five seeds fail twelve tests. - - Generators (§5): state besides the wrapped value is CMTK1012 (a `Currency` lost in JSON, a lazy - cache that made equal instances unequal, `required` as LAMA0611 CS9035); a hand-written equality, - `PrintMembers` without `ToString()`, and explicit implementations of the interfaces the - generators implement are CMTK1011, or CMTK1008 for comparison (an explicit `IParsable.Parse` - let a generic `T.Parse` past `Create`); a declared `IConvertible` is CMTK1011 (it was LAMA0041); - a `file`-local value object below a directive or nested in a `file` type is CMTK1005 (it was - LAMA0001, failing the whole project); an explicit `Create` beside a public one is CMTK1011, since - a generic `T.Create` reached a second rule set; and what a base record declares counts as the - value object's own (an unsealed `ToString()` on the base was replaced and printed the value). - The first CMTK1012 refused every record base from another assembly (its compiler-generated - `EqualityContract`); the check now skips `[CompilerGenerated]` members. Decision 32. - - Satellites (§4, §7): a container of nullable value objects, and a nullable value object inlined - by `CreateSchemaReferenceId`, admit null as the wrapped type does, in 3.1 and 3.0 (decision 36); - `InlinedValueObjectTests` compares 34 positions with the wrapped type. On SQLite an integer - value-object key is not `AUTOINCREMENT`, so the model never matched its snapshot and `Migrate()` - threw: the README documents `UseAutoincrement()`, and `MigrationSnapshotTests` checks it - (decision 35). The READMEs state the 10.0.12 floors (NU1605 below them), `AddTypeKit()` per - document or `ConfigureAll`, that `UseTypeKit()` needs a relational provider, and - that value objects with one name in different namespaces share a component, as any type does. - - Packaging (§6): the SBOMs also exclude what the SDK adds (`Microsoft.NET.ILLink.Tasks`, - `NETStandard.Library`), read from restore output (`suppressParent: All`); the ASP.NET Core - satellite drops `Microsoft.AspNetCore.OpenApi.SourceGenerators`, which had baked ten unused types - into it (assembly 229 → 37 KB), held by `GeneratedCodeTests`; symbol packages are pushed in a step - of their own, since a duplicate .nupkg made NuGet skip its .snupkg on a re-run; package - descriptions are plain text, and the release notes omit the changelog heading. - - Analyzer gaps, closed before the tag: CMTK0003 reports a collection of results the statement - made (`Task.WhenAll`, `Select(…).ToList()`) and a method group of a `Task` method - converted to a delegate returning `Task` (14 seeds); CMTK0008 reports a LINQ join on two - different value objects' values, query and method syntax (8 seeds); CMTK0009 reports `Find`, - the immutable collections' own `…OrDefault`, and the async forms of `System.Linq.AsyncEnumerable` - and EF Core (7 seeds). CMTK0001 no longer reports a `default` passed to a comparison guard or an - assertion, or assigned to the `out` parameter of a `bool` Try method (11 seeds). - - Refusals, after a reviewer's note that the refusal-heavy design will draw "why does this throw?" - reports: every deliberate run-time refusal ends with a link to its subsection of the base README's - "Why does this throw?" (`Refusals`, `RefusalLinkTests`, 38 refusals), the two raised by generated - code (`From(null)`, a JSON null) through `CompilerServices` helpers; every CMTK1000–CMTK1012 - message ends with a link to the Generators README's Build errors table, since a Metalama 2026.1 - `DiagnosticDefinition` takes no help link (`BuildOutcomeTests`). `OrNull()` for an `Option` of a - value type (`ValueTypeOptionExtensions`), so the JSON refusal's advice compiles for `Option`. - A nullable `Option`/`Result` is refused with its own message, null or not: writing null threw - `NullReferenceException`, and reading an option gave the `Result` advice. A source-generated - context cannot refuse `Option?` cleanly (it would need `MakeGenericType`) and rejects the - contract when it builds it; the README says so. Decision 37. - - Documentation: the root README gains "How it compares" (when TypeKit fits, a decision table of - needs another library meets better, and detail tables against Vogen, Thinktecture, - StronglyTypedId, LanguageExt, CSharpFunctionalExtensions and ErrorOr, each claim read from source - at the named release on 2026-09-28, the next major in preview included) and "Built with Metalama". - The Generators README links to the comparison. Both are GitHub-only and can be corrected without - a release. - - Found and left for 1.x: CMTK0003 over `.Wait()` and `await foreach` (closed in 2.0.0 before the - rule became an Error, decision 50); CMTK0008 over tuple `==`, `Contains` and `is var`, and - composite join keys (a Warning, decision 34). Raw Metalama errors - for a value object named after a generated member (`From`, `TryFrom`, `MinValue`), a wrapped type - whose `Parse` does not return it or that is abstract, and a public base member whose signature - matches a generated one (LAMA0500). `ToString()` of a default `Uri`/`Version` value object throws; `IConvertible` quotes - the value; `class Email : IValue` reports one of its three errors at a time; `IValue` - writes `{}`. JSON on `Option?` throws the wrong exception; a default marker converts. - `UseTypeKit()` on a non-relational provider could refuse in `Validate`. -12. **1.5.0: the boundaries. ✅ Done 2026-10-11.** Every item is additive, so it is a minor version. The - scope follows a survey of the competing libraries' trackers on 2026-10-11 (demand is small - everywhere, at most 22 reactions on one request), keeping what no library ships or what only - preview packages serve, and what follows from what TypeKit already holds to. - - **A `Result` as an HTTP response**, in `CodoMetis.TypeKit.AspNetCore`: a success as its value, a - fault as RFC 9457 problem details, with its status mapped from the fault in the host, so the - domain never references ASP.NET; and the endpoint's responses in the OpenAPI document. Mapping a - result to a response is not serializing it, so §9's refusal stands. The most-requested open item - in the field (FluentResults #149); the packages that answer it are previews or unmaintained. - Measured 2026-10-11 in [spikes/ResultHttp](../spikes/ResultHttp/README.md), under JIT and Native AOT, - minimal APIs and MVC: an explicit `ToHttpResult()` returning ASP.NET's own - `Results, FaultProblem>`, a mapping per fault type registered in the host - (`AddFaultResponses`, which calls `AddProblemDetails()`, without which Native AOT writes no - problem), endpoint metadata recording only the fault type (MVC hands `PopulateMetadata` an empty - service provider), an operation transformer adding a problem response per mapped status, and a - startup filter that refuses a host with an endpoint whose fault type has no mapping. Returning - the `Result` itself from a handler is not offered: it documents a `Result`, and a Native AOT host - would need it in its JSON context. **✅ Implemented 2026-10-11** (decision 38): `FaultResponseTests` - (27, against ASP.NET's own `ProducesProblem` as the control, in OpenAPI 3.1 and 3.0, minimal APIs - and MVC), the README's sample and its problem JSON read from the README, and the smoke test's - Native AOT consumer. Fifteen seeds each fail their guard. The weaving review found one guard - vacuous: every host also mapped ProducesProblem controls, so a problem component the package - never added went unnoticed; a host without them now holds it. Without `AddProblemDetails()` only - the `traceId` assertion notices under JIT, so the AOT consumer is its real guard. A handler typed - `IResult` loses the endpoint's metadata, as ASP.NET's typed results do; the README says so. - - **A value object's fault as a validation error.** A body or parameter that `Create` refuses is a - `400` whose problem details name the member and the fault, as ASP.NET Core 10's validation - reports a data annotation, instead of a bare `400` from a `JsonException`. No library does this. - Measured 2026-10-11 in [spikes/ValidationFaults](../spikes/ValidationFaults/README.md): ASP.NET - Core 10's validation never sees a value that failed to bind, a refused route or query value has - lost its fault (`TryParse` says only `false`), and MVC already puts the converter's message in its - validation problem. What remains is a minimal-API body, reachable only with `ThrowOnBadRequest` - turned on for the whole host and an exception handler, and only once the converter's refusal - carries its fault as data. The README's "Binding" said `AddProblemDetails()` gives a refused body - a response body; it needs `UseStatusCodePages()` too, which it now says. **Dropped 2026-10-11** - (decision 39). - - **`CodoMetis.TypeKit.Testing`**: assertions for `Option`/`Result` (`ShouldBeSome()`, - `ShouldBeError(fault)`), which a type without `.Value` needs more than any other, and generators - of valid and refused values that go through `Create`. **✅ Assertions implemented 2026-10-11** - (decision 40): `AssertionTests` (23), the README's block run as a test and its quoted failure read - from the README, the smoke test's core consumer installing the package. Generators are not built - (decision 40). - - **Sensitive value objects**: a marker that keeps a value out of `ToString()`, the debugger and - logs (`Microsoft.Extensions.Compliance` classification, structured-logging destructuring). It - extends decision 27 from refusals to every place a value is printed. No library does this. - Measured 2026-10-11 in [spikes/SensitiveValues](../spikes/SensitiveValues/README.md): the - `ToString()` seam the generators keep already masks a value in every formatting path and every - Microsoft.Extensions.Logging path, `[LogProperties]` included, so a marker would generate the one - line a user writes. A classification on the type makes the logging generator refuse the method - (LOGGEN035). The one leak is Serilog's `{@x}`, which destructures the public `Value` of every - value object, masked or not; a destructuring policy closes it. **✅ Implemented 2026-10-11** - (decision 41): no marker; the Generators README's "A value that must not be printed", and - `CodoMetis.TypeKit.Serilog`. - - **`Recover` on `Result`**: the error branch back to a success, which today needs `Match`. - **✅ Implemented 2026-10-11** on both shapes, with `RecoverAsync` (an asynchronous selector, and - continuations on a pending result). It returns a `Result`, so a selector recovers from some - errors and returns the others; the completeness tests (continuations, null delegates, - uninitialized results) found every new member until each had its case, and four seeds each fail - their guard. - - Candidates once those ship, each with its own measurement: a Dapper satellite (nothing registered - per type, through `GeneratedValueObjectAttribute.Accept`), Newtonsoft.Json under decision 26's - parity, and configuration binding, which the configuration binder's source generator does not do - for `TypeConverter`/`IParsable` types (dotnet/runtime#83599). The CMTK0003/CMTK0008 gaps above - stay Warnings (decision 34); CMTK0003's are closed in 2.0.0 (decision 50). - - **Ranges are not a TypeKit family**: CodoMetis.ValueRanges models them. A value object over one - (`IValidatedValue`) works with both packages as they - ship, measured 2026-10-11 in [spikes/RangeValueObjects](../spikes/RangeValueObjects/README.md): - - it weaves, so phase 11's "abstract wrapped type" gap does not cover a type that parses to itself; - - `UseTypeKit()` composes onto the `daterange` mapping in either order; - - `.Value.Contains(day)` becomes `@>`; - - the generated JSON and OpenAPI mirror `DateRange` exactly. - - No bridge package is needed. ValueRanges 9.0.0 (re-measured the same day) closes the three wire - gaps the value object had inherited: `[JsonConverter]` on every range type, ranges as dictionary - keys, and an OpenAPI transformer to copy, which reaches the value object through `AddTypeKit()`. - The re-run found a TypeKit defect, **fixed**: a value object's `TryParse`-bound parameter took the - wrapped type's JSON schema (`{}` over `DateRange`), where ASP.NET describes a parameter of the - wrapped type as `string` for anything off its own list (`EndpointModelMetadata.GetDisplayType`). - `ParameterTests` has a control for each entry of that list and one off it (`ProbeSpan`). Open: a - README section for the pattern, with no host registration needed for JSON; and CMTK1005 refusing - `RangeSet<,>`, whose equality is a value's, though `IEquatable` alone is no criterion - (`ImmutableArray`). - -13. **2.0.0: closed sets, fault responses, merge patches, exceptions, constraints and text.** In - progress: steps 1 to 9a done (2026-10-13), the preview and the release to come. Scoped 2026-10-12 (decision 42): the features proposed for 1.x ship in the major, - not as a 1.6.0 before it, because the smart-enum family changes what a fault is, and fault - responses built on enums first would be built twice. Pinned 2026-10-13 (decision 44) to - `net10.0` and Metalama 2026.1: nothing in it needs .NET 11, C# 15 unions or Metalama 2027.0, and - it waits for none of them. The build order, each step a commit with its tests, and a spike - before the part where one is listed: - 1. **Open the release** ✅ 2026-10-13: `Version` 2.0.0 and a `## 2.0.0 — Unreleased` changelog - section. The package-validation baseline is 1.5.1, the last release (it had stayed at 1.5.0, - which `ChangelogTests` only sees once the version being built moves on), and reports nothing - until the first intended break. After moving a baseline, restore the solution: the conventions - pack with `--no-restore`, and a baseline not yet in the local cache fails every pack. - 2. **The text guards** ✅ 2026-10-13: `StringTextExtensions` and `TextFault` in the base package, - `StringTextExtensionsTests` (the character table, pinned by code point), the README section - "Written text" with its two compiled samples. No spike. - 3. **`Result.Catch`** ✅ 2026-10-13, after [spikes/ResultCatch](../spikes/ResultCatch/README.md). - The spike settled the shape: the async trap is an `[Obsolete(error: true)]` overload over a - task-returning delegate, more specific than `Func`, so the compiler refuses it and names - `CatchAsync`; its cost is that a lambda that only throws must state its return type. No - `ValueTask` overloads: beside `Func>` they are ambiguous for every `async` lambda, as the - README already says of `MapAsync`. `Catch` spells both type arguments, and the - one-argument `Catch(Action)` is the command form. A delegate that returns null throws - rather than being caught: the value is computed inside the `try`, the success built outside it. - 4. **CMTK0011 and its code fix** ✅ 2026-10-13: `[RequireParameterlessConstructor]` and - `ConstructorAccessibility` in the base package, `RequiredParameterlessConstructorAnalyzer` in - `.Analyzers`, the fix in `CodeFixes/`, a project nested in the analyzer project's folder and - packed into the same package under `analyzers/dotnet/cs`: an analyzer assembly must not - reference Workspaces (RS1038), and a sibling under `src/` would be a package of its own to the - conventions. The fix spells the id and the property name it reads, held to the analyzer's by a - test. Named `Require…`, beside `[RequireCustomInitialization]`, since the rule requires and the - fix writes; nothing is introduced. - 5. **CMTK0010** ✅ 2026-10-13, after [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md), - run with the real rule over a library, a consumer and a Razor component: every seeded breach - reported once, the forwarding hole closed (a type parameter carries the attribute, or `struct` - and `new()` prove `[Sealed]`/`[Concrete]`), nothing for the open generic in its own body. The - attributes are `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` in the base package; a - generic name is checked where it is written, at the argument, and a call with inferred type - arguments at the call. - 6. **`Patch`** ✅ 2026-10-13, after [spikes/MergePatch](../spikes/MergePatch/README.md), in the - base package and the ASP.NET satellite. The spike settled the AOT shape: a converter type built - with `MakeGenericType` has no constructor to invoke under Native AOT, a static field read by - reflection is trimmed, an open generic converter in `[JsonConverter]` is refused by the source - generator (SYSLIB1220); what works is a registry the closed `Patch`'s static constructor - fills, which the factory runs for the type asked for (`RunClassConstructor`, one IL2059 - suppression with that measurement as its reason). A primitive inside a patch takes the - serializer's built-in converter, since the source generator never sees what a patch wraps; a - class is listed in the context. The document inlines a patch as its argument's schema, never - required, always admitting null; a value object inside a patch is described through what it - wraps, since a source-generated host has no contract for it. - 7. **The closed-set family** ✅ 2026-10-13, after [spikes/ClosedSets](../spikes/ClosedSets/README.md), - which answered the compatibility question: a generated type with static members on Metalama - 2026.1 and `net10.0`, nothing newer needed. `IClosedSet` in the base package, - `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1021, the - discovery attribute `GeneratedClosedSetAttribute` with a typed visitor, a text column in - the EF satellite and a string enum in the OpenAPI satellite. The fault bits (a kind and a - sentence per member) are step 8's, since the fault responses read them. Two reviews the same - day: the first's fixes are CMTK1019–1020 and the kept `ToString()`; the second's are CMTK0012 - (arguments by position), CMTK1021 (a member named as a generated member, or two sharing a - `Match` parameter), null delegates refused before a branch is picked, dictionary keys, and - `ClosedSetConverter`: the compiled model wrote the internal mapping's helpers out as C# - in the application (CS0122), so no closed set reached Native AOT. The smoke test's `aot` - consumer now publishes a set's JSON, its column, a parameter and a JSON column. - 7a. **The research picks** (2026-10-13): a research agent reviewed what 2.0.0 lacks against the - peers and the issue trackers, and the user took its top five, each a commit: - `FirstOrNone`/`FirstOrError` on a query and EF's `FirstOrNoneAsync` ✅ (decision 47); a closed - set's `[TypeConverter]` and `IParsable` ✅ (explicit, since `Parse(string)` is its surface; the - reflection binder now binds a set; the binder's source generator reads no type converter, - dotnet/runtime#83599); Serilog for closed sets ✅ (measured first: `{@Tier}` logged - `ProbeTier { Rank: 2, Perk: "Priority support.", … }`); continuations of a - `Task>` ✅ (the `Task` set's shapes: no `OrAsync`, no `MatchAsync`, no - `ValueTask` receiver); `Transpose`, `SingleOrNone` and `ElementAtOrNone` ✅ (more than one is a - refusal, as `SingleOrDefault`'s; a negative index on a query is `None` without a query, since - `Skip(-1)` skips nothing). - 8. **The fault responses** ✅ 2026-10-13, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), - whose candidate was the satellite itself and whose thirteen claims are its tests and the smoke - test's AOT consumer. In the base package: `FaultKind`, `IFault` and `IFaultSet` (a kind - and an optional description per member), and `Fault` (a reason and a detail, refused - by JSON and by CMTK0001 when `default`). In the ASP.NET satellite: one internal `FaultType` - for an enum and a closed set alike, `FaultResponse` (status, `type`, `title`), - `AddFaultResponses()` by kind, `WithFaultStatus` and `[FaultStatus]`, - `ToHttpResult(success)`, `IFaultFeature` and `FaultResponseOptions.QualifiedFaultNames` - (decision 49). `FaultProblem`'s constraint relaxed to `notnull`, the first difference - the 1.5.1 baseline reports, suppressed alone (decision 48). - 9. **The breaks** ✅ 2026-10-13 (decision 50). CMTK0003 is the one Warning raised to Error, after - its gaps were closed: `Wait` on a task the expression made, tasks of results made in the - statement converted to plain ones (`Task.WaitAll`, `WaitAny`), a `foreach` or `await foreach` - whose body never reads the results its header made, and a lazy sequence of results a call made, - materialised and dropped (eleven seeds, each failing its guard; an explicit cast to plain tasks - stays silent, as a cast to `Task` does). The release-tracking file - records the change (RS2001 refuses a descriptor that disagrees, RS2007 a change that is none), - `AnalyzerHelpLinkTests` holds the README's severity column to every descriptor, and the - build-outcome consumer no longer raises CMTK0003 itself, so its reports there prove the - default. CMTK0010–0012 needed nothing: they ship at Error in this major and report only on - what it adds. The 1.5.1 baseline, run over all seven packages with the attribute and - parameter-name rules on as well, reports the one suppressed difference and compiler-emitted - attributes only. - 9a. **A review's findings** (2026-10-13), each a commit: - - **`Catch` over a task** ✅: the command form `Catch(Action)` took a delegate - returning a task and discarded it, an `async` lambda as `async void`; a task spelled as the - type argument (`Catch, …>`) and an inferred `ValueTask` were a success holding the - task. `Catch(Func)` is refused at compile time, and so is - `Catch(Func)`, any value the command form would discard, which is the - only overload a `ValueTask` reaches (the spike's ambiguity rules out `ValueTask` - overloads beside `Task` ones: measured again, every `async` lambda became CS0121). A task - as the value, spelled or inferred, is refused at run time before the delegate runs, and in - `CatchAsync` a task whose value is a task. Two-type-argument refusals were measured and - dropped: they make a lambda that only throws, or returns `null!`, ambiguous, and the calls - they would refuse are already the compiler's conversion errors. - - **A patch's schema refused or admitted the wrong values** ✅, measured by validating values - with JsonSchema.Net (test-only) against the document, 3.0's `nullable` read as 3.0.3 defines - it: a string enum's patch was `"type": "null"` beside its names and admitted nothing, a - closed set's listed no null in its enum and refused it, and `Patch` was - `"type": "null"` and admitted only null. Null now joins what restricts the values: the - enum's list, the type, or, for a schema combining subschemas, a branch beside them. - ASP.NET's own polymorphic schema needed no branch, its derived schemas stating no type; a - host's `oneOf` of typed schemas does, and guards it. - - **CMTK0010 over a method group** ✅: `Action run = Runner.Run;` infers the - type arguments from the delegate, with no generic name written and no call, and was - silent. Every method reference is checked as an inferred call is, at the group; a group - with its arguments written stays the generic name's, reported once. - - **A closed set's `All` was writable** ✅: it was the backing array, so a cast to - `IList` or `T[]` replaced a member that `All`, the JSON schema's enum and every - enumeration then listed, while `Parse`, which reads the fields, answered the original. It - is the array wrapped by `Array.AsReadOnly`, whose writes throw `NotSupportedException`. - - **Invisible text passed `AsText()`** ✅: a value of joiners alone (`"\u200D"`), of variation - selectors alone (filed as non-spacing marks, so no category test reached them), or of a - lone combining mark rendered nothing and was text. A value now needs a base character, and - a variation selector stands only directly after one, so emoji presentation, a flag, a - keycap and an ideographic variant survive; a joiner is not placed, since an Arabic letter - shown in its joining form on its own ends with one. - - **The docs** ✅: the README no longer lists fault responses (documented in the ASP.NET Core - README and guide since step 8) or public aspects (none planned) as still to come; - `docs/migrating-to-2.md` lists what builds or runs differently for 1.5 code, compiled like - every page; and the Native AOT page states the query helpers' limitation, which the smoke - test's `aot` consumer now measures: the base package's `FirstOrNone` on a query and the - satellite's `FirstOrNoneAsync` throw "Query wasn't precompiled", and the operators written - at the call site run. - 9b. **A second review's picks** (2026-10-13): two outside reviews of the release were checked - against the code (one named `Choose`, result aggregation and a dictionary lookup as missing, - where `Zip`, `Sequence`, `Traverse` and `GetValueOrNone` exist, and read CMTK0006 as a `.Value` - guard), and the user took what held, each in this release: - - **`Choose`** ✅: on a sequence with a selector answering an option, and on a sequence of - options; lazy, the arguments checked at the call (F#'s `choose`, CSharpFunctionalExtensions' - `Choose`). - - **`Patch.Map` and `Traverse`** ✅: a patch over what the request sent, validated into a - patch over the domain's type with `Create`'s own fault, a clear and an absence passing - through, `Option.Traverse`'s pattern; a `Map` selector answering null is refused, since - a clear is the Null state and never a value. - - **`TraverseAsync`** ✅, sequential: one operation awaited at a time, in order, nothing - started after the first error; the token overloads check it before each operation and hand - it on, and a cancellation propagates, as `Catch` lets it. Concurrency stays explicit, - `Task.WhenAll` and `Sequence()`. - - **Patch assertions** ✅ in `.Testing`: `ShouldBeValue()` hands the value back, - `ShouldBeNull()` and `ShouldBeAbsent()` name the state, never the value. - - **A closed set parsed ignoring case** ✅ (decision 51): `Parse(name, ignoreCase)` and - `TryParse(name, ignoreCase, out member)`, opted into at the call; two members whose names - differ only in case are CMTK1021, which subsumes the `Match`-parameter collision. - - **The README** ✅ refreshed for 2.0.0: the status, the comparison tables' version, the pitch - naming closed sets and the release's other additions, and a closed-set comparison against - Ardalis.SmartEnum 8.2.0, Intellenum 2.0.1 and Thinktecture.Runtime.Extensions 10.5.1, read - from source and nuspecs on 2026-10-13. - - Not taken: error accumulation and a `Combine` (§9 non-goals). A stable external name for a - closed-set member, apart from its C# identifier, touches parsing, JSON, EF, OpenAPI and fault - identifiers at once and needs a spike first; it is 2.x. - 9c. **An outside review before the tag** (2026-10-13, a separate Opus 5.5 session over the three - commits and the library), its must-fix findings each fixed with a seeded test: - - A closed set in two partial files listed the second file's members as null in `All` (a field - initializer runs with its own part); `All` is filled by the names initializer, after every - part's fields. Two nested sets of one simple name shared a `QualifiedName` prefix and one - OpenAPI component; both are the nesting chain. A non-public static field of the set's type - was an instance that is no member; CMTK1017. The nested converters are sealed. - - A closed set was a string enum as a body property only: a route or query parameter was a bare - `string`, a container's element was missing. The transformer takes the value-object - transformer's three positions. - - `ElementAtOrNone(^n)` on a query read every row through the sequence overload; refused as - `LastOrNone` is. `fault == reason` on a `Fault` compiled and was false with a detail; - the mixed forms are refused, naming `Reason`. `CatchAsync` over a delegate returning no task - was an error holding a `NullReferenceException`; it throws before awaiting. - - CMTK0010 reported doc-comment crefs and called arrays not sealed; a method implementing or - overriding a constrained method without the attribute let a call through the class pass. - CMTK0003 missed a result task picked by a conditional or a `switch` expression and a method - group answering a collection of results: decision 50's "complete" was not, and is now - measured by those forms too. - - Documentation: the claims the review found misleading corrected (a set as a parameter, the - `Index` form on a query, "everything works" under Native AOT, `AsText()` and line breaks, - `CatchAsync` over a null task, the `ignoreCase` overloads and binding, the Testing messages' - error, the EF limitations' count), the migration guide's name list completed, `Fault` under - "Not wire types", `WithTitle`, the command `ToHttpResult(success)` and `[FaultStatus]` on a - class documented, the package descriptions naming closed sets, and the prose it flagged. - - Can wait for 2.1, recorded here: the fault name is sent even for a member without a - description; `Uri.ToString()` unescapes the problem `type`; the CMTK0011 code fix on a partial - type with a primary constructor elsewhere, and its doc comment beside an attribute; CMTK0003 - over `WhenAny` of held tasks; `Choose`'s `notnull` receiver; `Patch` under `Traverse`. - 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test - against it, then the release. - - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` (§1), the - release's headline. A member is a named instance with constant data. The generators emit `All`, - parsing and formatting by name, JSON, an OpenAPI string enum, and a `Match` with one required - delegate per member. C# never proves an enum `switch` complete; it only warns about a missing - discard (CS8524). As a fault type, the family carries what `FaultKind` (proposed 2026-10-11, - folded in here) would have read from attributes: - - **A kind per member**: NotFound, Conflict, Invalid, Forbidden or Unavailable, nothing - HTTP-specific, from which `AddFaultResponses` derives a status without a hand-written switch. - It is a property set where the member is declared, so Native AOT reads it without - reflection. The explicit mapping still overrides it, so decision 38's switch remains the - escape hatch. - - **An optional sentence per member**: the fault's remedy as a person reads it. It is optional - because a refusal that must not explain itself, a credential's or a secret's, has none on - purpose. - - **A generated qualified name** (`OrderFault.NotFound`) beside the bare one, so two fault types - that share a member name stay apart in one client. - - A smart enum names a fault. It does not replace an error that carries data: a closed set of - shapes (`NotAmendable(Status)`, `OutsidePeriod(Date)`) is what C# 15 unions are for. Translating - one names a smart-enum member, and what varies per occurrence travels as - `Fault.Detail` (below). A spike must answer: - - **Which status for each kind**, and whether a host may change a kind's default. - - **The startup check holds**: a member with neither a kind nor an explicit mapping still stops - the host. - - **A generated type with static members**, not a C# 15 union: 2.0.0 is `net10.0` (decision - 44), and a union-shaped closed set is 3.0.0's question. - - **Persistence**: an EF column by name, mapped by `.EntityFrameworkCore` as value objects are. - - **Fault responses a client can act on**, proposed 2026-10-12. Decision 38's response carries a status and the fault's bare name, and its success is - `200` or `204`. An application whose client shows a refusal to a person, creates resources, or - reads with an ETag has to leave `ToHttpResult()` for each of those endpoints. The libraries - surveyed on 2026-10-12 map centrally too, but none models `Location` or `ETag`: Ardalis.Result - 10.1.0, ErrorOr 2.1.1 and FluentResults. Ardalis and FluentResults allow a per-call status. RFC - 9457 §3.1.1 makes `type` the primary identifier, and §3.1.4 has `detail` help the client correct - the problem. Five additive parts, each opt-in, so decision 38's default response is unchanged: - - **A response per member**: `AddFaultResponses(f => FaultResponse.Status(409).WithType(uri))`, - an overload beside the `int` one, checked for every member at registration as that one is. It - adds `type` and `title`, which name the problem type and nothing of the input. - - **A sentence per occurrence**: `Fault(TFault Kind, string? Detail)`, a record struct in - the base package. `Result>` infers `TFault` from a type argument, which an - `IFault` constraint could not, since C# infers nothing from a constraint. The mapping and - the document stay keyed on the enum. The sentence is the application's, so "the problem names - the fault, never the input" becomes the default rather than a guarantee, and the README says so. - - **A status per endpoint**: `.WithFaultStatus(OrderFault.NameTaken, 422)` as endpoint metadata, - read by `FaultProblem`, the operation transformer and the startup check. The same fault can - mean 404 on one route and 409 on another (RFC 9110 §15.5.10 defines 409 by the target resource). - - **The success the endpoint chooses**: `ToHttpResult(order => TypedResults.Created(uri, order))`, - returning `Results>`, so ASP.NET still describes the success. An - ETag needs a result of the application's own, and this is the hook for it. - - **The body the host shapes**: the fault reaches `CustomizeProblemDetails` as a request feature - (`IFaultFeature`: the fault type, the member, the detail), so a host can add extensions such as - an error code without the package naming any, plus an option to qualify `fault` as - `OrderFault.NotFound`, since two enums can share a member name in one client's switch. - - A spike must answer, in [spikes/FaultResponses](../spikes/FaultResponses/README.md): - - **Native AOT** for each part. - - **One reading of a fault.** The mapping, the startup check and the document read a fault type - through one internal abstraction, never `Enum` directly, so an enum fault and a smart-enum - fault (above) take the same path. - - **Inference**: `ToHttpResult()` over `Result>` and over `Result` - resolves without type arguments and without an ambiguity between the two. - - **The document**: a per-endpoint status is listed for that operation only, a `type` URI appears - in it, and `fault` stays a string on the shared `ProblemDetails` component. A per-operation enum - is optional, never a union over every fault type, because a fault type added later would change - every operation's schema. - - **The startup check** refuses a `WithFaultStatus` for a fault type the endpoint does not answer - with, and a member that the mapping, its kind and the endpoint all leave unmapped. - - **MVC**: the endpoint metadata reaches the transformer, where finding 4 found - `PopulateMetadata` handed an empty service provider. - - **`CustomizeProblemDetails` sees the feature** under JIT and Native AOT, and the feature is - absent for every problem `ToHttpResult()` did not write. - - **`Patch`, a field of a JSON Merge Patch (RFC 7396)**, proposed 2026-10-11 for 1.x; built 2026-10-13 (step 6 above answers the questions). It has - three states: absent, null, or a value. System.Text.Json cannot tell an absent property from a - null, so every PATCH endpoint rebuilds this type by hand. It is a wire type on purpose, beside - `Option`, which still refuses JSON (decision 15): `Option` says whether a value exists, `Patch` - whether a request mentioned it. A spike must answer: - - **`default` is a state.** The serializer calls no converter for an absent property, so - `default(Patch)` has to mean "absent". That makes this the one type of the package whose - `default` is meaningful. Is it exempt from CMTK0001 by its contract, and does that stay - clear of `[RequireCustomInitialization]`? - - **Native AOT.** A converter factory over an open `T` is `MakeGenericType`, which the refusing - converter avoided only because it never reads `T`. Find what works under a source-generated - context: an attribute per closed type, or a generated converter. - - **Value objects.** `Patch`: a JSON null and a value `Create` refuses each give the - response decision 39 measured. - - **The OpenAPI document.** The property is not required, and admits null exactly where `T` - does, in 3.1 and 3.0. - - **Applying it.** Without reflection over the target (§11): `patch.Name.IfPresent(v => order.Name = v)`, - not an `ApplyTo(entity)` that walks properties. Optionally an analyzer for a `Patch` that is - bound and never read. - - It belongs in the base package, its OpenAPI part in the ASP.NET satellite. It is not ASP.NET - Core 10's JSON Patch (RFC 6902, a list of operations). - - **`Result.Catch`: an exception as an outcome**, proposed 2026-10-13. A call that throws has no - way into a `Result` today, so every caller writes the `try`/`catch` by hand, and a `Try` - type would be `Result` under a second name (§9). Four factories on the `Result` - static class, each returning a concrete result rather than a marker, since the delegate fixes - `T`: - - **`Catch(Func)`** to `Result`, and **`Catch(Action)`** to - `Result`, the value-less shape, which is why no `Unit` is needed. - - **`Catch(Func)`** to `Result`, catching that type and - letting every other exception through, so the error type says what was expected to fail. - - **`CatchAsync`** for each, over `Task`, `Task` and `ValueTask`. - - **A cancellation is not an outcome**: `OperationCanceledException` passes through every - overload. Caught, a cancelled request would read as a failed one and `Recover` would run on - it. - - `Map`, `Bind` and the rest stay as they are: a selector that throws propagates, as it does - now, so the one place an exception becomes data is the one that says so. A spike must answer, - in [spikes/ResultCatch](../spikes/ResultCatch/README.md): - - **Inference**: `Result.Catch(() => int.Parse(s))` infers `T`; `Catch` needs - both type arguments spelled, since C# infers all or none, unless a different shape avoids it. - - **The async trap**: `Result.Catch(() => client.GetAsync(url))` binds `T` to a `Task` and - catches nothing the task throws. Whether an `[Obsolete]` error overload, a CMTK rule, or - both, refuses it. - - **`ToHttpResult()` over `Result`** is a compile error, since an exception is - not a fault a client acts on. - - **Native AOT**: nothing new is reflected, and the smoke test's `aot` consumer says so. - - **Constraints a type parameter cannot declare**, proposed 2026-10-13. C# has `where T : class`, - `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` or `record`. A generic - base whose type argument must be an abstract record, such as the command or event family of an - aggregate, gets no help from the compiler, and the closed type that violates it compiles. - Attributes on a generic parameter in the base package, `[Abstract]`, `[Sealed]`, `[Record]` and - `[Concrete]`, and one analyzer rule, CMTK0010, reported at every closed use: a base type, an - implemented interface, a field, a parameter, a `typeof`, a call with its type arguments - inferred. An analyzer rather than an aspect, because the violation is at the use site, which - need not compile with Metalama, and the base package is Metalama-free (§3); an aspect on the - parameter would see the declaration and never the use. A spike must answer, in - [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md): - - **Forwarding**: `class Sub : Base` where `Base<[Abstract] T>` is a violation unless `U` - carries `[Abstract]` too, or the hole is the whole rule. - - **Every use site** is reached, inferred type arguments and generic methods' parameters - included, and nothing is reported twice for one site. - - **Reflection** (`MakeGenericType`) is out of reach, and the README says so. - - **Error in a major version** is within decision 34; the rule never ships at Error in a minor. - - **`[RequireParameterlessConstructor]`** (proposed as `Ensure…` 2026-10-13; `Require…` as built, beside `[RequireCustomInitialization]`). A type the host creates by - reflection, an endpoint, a module, an entity configuration, needs a parameterless constructor - of a given accessibility, and C# checks that only through `new()`, which demands public. An - attribute in the base package, on an interface or a base class, and an analyzer rule, CMTK0011, - on every concrete type that implements or derives from it, directly or through another type, - with a code fix that writes the constructor. An analyzer and not an aspect, although an - `[Inheritable]` aspect introducing the constructor was the first shape considered: - - **The check is the feature.** The constructor is one empty line; the value is the error at - the type that lacks it, and that error has to appear in a project that never compiles with - Metalama, where an inheritable aspect is silent, this repository's characteristic failure. - - **A code fix beats an introduction.** An introduced constructor demands `partial` on every - implementer and is invisible in the source; the fix writes it where the reader looks, and a - type that declares no constructor needs nothing, since it already has a public one. - - **What the rule checks**: a parameterless constructor of the required accessibility exists, - or none is declared and public was asked; a record's copy constructor is not one; a wrong - accessibility is reported at the constructor, with the fix changing it; an abstract type or - an interface is not checked, only its concrete descendants. - - **Public aspects stay possible** (§5): an aspect that is itself the feature may be public, - with the attribute and its diagnostics as its surface. This one is not the first, because - its feature is the check. - - **Written text**, proposed 2026-10-13. Trimming, refusing the characters that render as - nothing and composing to NFC is written again in every application that stores what a person - typed, and `IsNullOrWhiteSpace` is what it usually gets, which lets a zero-width space, a Hangul - filler and NUL through, and keeps a name written with `ü` and the same name written with `u` and a - combining diaeresis as two rows of a unique index, rendering alike. Two string extensions in the base package, each returning - `Result` with `TextFault.Blank` or `TextFault.Hidden`: - - **`text.AsText()`**, for text a person wrote: trimmed, every space separator folded to - `U+0020`, NFC-composed, every character that renders as nothing refused, the two zero-width - joiners tolerated because they spell Persian, Arabic and Indic scripts and emoji sequences. - - **`text.AsIdentifier()`**, for a value another system issued, a subject claim, a session id: - trimmed, nothing else altered, since canonicalising somebody else's identifier changes an - attested value, and the joiners refused too, since no issuer needs one. - - **A null receiver is `Blank`**, since the extension is the door a bound request property goes - through, and binding enforces no non-nullability. - - **Two faults, not one**, because a required field makes `Blank` an error and an optional one - makes it absence; `Hidden` is a refusal in both. - - **The scan is by `Rune`**: a `char` loop reports `Surrogate` for a character above the BMP and - misses the tag characters used to hide text in text; an unpaired surrogate is `Hidden`, not a - throw, since `Normalize` and a database driver both throw on it. - - **Not refused**: unassigned code points, since .NET's tables lag Unicode and a name spelled - with a newly assigned character would be refused until the SDK caught up; confusable letters - across scripts, which is a comparison-key question and not a rejection. - - No value object ships with them, and no `.Text` package either (weighed 2026-10-13). The base - package is Metalama-free, so it cannot declare one for the generators, and a hand-written one is - what CMTK0002 exists to refuse. A package shipping a woven one would reference `.Generators` - with `PrivateAssets="all"` to keep Metalama away from its consumers, and would need an - exemption in the Metalama-boundary conventions, a package-validation baseline override, a - smoke consumer of its own under Native AOT, and a third state in the README's Metalama column. - All of that buys one line per field, and the type is a dead end: a field that later needs a - length limit cannot wrap it (CMTK1005) and is rewritten over `string`, and two fields of one - shared type lose what CMTK0008 and assignment give two named types. A consumer declares its - own in one line, `Create` being `value.AsText().Map(…)`, and names it for the field - (`Description`, `DisplayName`). The README shows that line. The extensions live in a class - named `StringTextExtensions`, so a consumer's value object named `Text` has no companion-class - collision with them (CMTK1007) and nothing is ambiguous with `System.Text`. If demand for a - ready-made type appears, a minor-version `CodoMetis.TypeKit.Text` holding `WrittenText` and - `IssuedIdentifier` under those conditions is additive; removing one after shipping is not. No - spike: every claim is a unit test over a string. - - **What 2.0.0 breaks** (decision 50): CMTK0003 at Error, the one Warning raised; CMTK0010–0012 - new at Error; `FaultProblem`'s constraint, suppressed alone against the 1.5.1 baseline - (decision 48); `FirstOrNone`/`FirstOrError` binding to the query overloads, and `LastOrNone` - on a query a compile error. - - **What it does not wait for** (decision 44): Metalama 2027.0 stable, .NET 11 and C# 15 unions. - The compiler-made union types have a public `object? Value` and are serialized by - System.Text.Json, so `Option`/`Result` take part in their pattern matching only after a spike - shows neither returns, and that spike belongs to 3.0.0. - - **What comes after**: the Metalama 2027.0 upgrade (decision 4) joins the first release after - it is stable, as a minor if a consumer's own aspects beside the packed generators still build - and weave, measured as the fabric spike measured 2026.1 against the 2027.0 preview, and as a - major otherwise; `[Durable]` returns to the `_value` placeholder with it. A `net11.0` target - joins as a second target in a 2.x minor when a feature needs it, at the cost priced on - 2026-10-12: EF Core 11 and Microsoft.AspNetCore.OpenApi 11 in the satellites and a Native AOT - smoke run per target. C# 15 unions and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS - (November 2027) or to the end of .NET 10's life, whichever its consumers need. - - **The preview** (`2.0.0-preview.N`) is 2.0.0's own, from `main`, on the .NET 10 SDK and - Metalama 2026.1, published to nuget.org once the closed-set family and the fault responses - exist, so the surface gets consumer feedback before the tag. The release workflow takes a - preview tag as it is, since `v2.0.0-preview.1` passes the tag check against `Version` and the - changelog check against a dated `## 2.0.0-preview.1` section. The measurement of unions and - Metalama 2027.0 on the .NET 11 SDK moves to a 3.0.0 spike on a branch of its own, with no - release attached. - -## 3. Discovery is by interface - -No namespace strings, assembly-name prefixes or type-name lists anywhere. A name-based check -passes in tests and breaks for the first consumer who renames something. - -| Where | How | -|---|---| -| Analyzer, value-object rules | `CompilationStartAction` resolves `CodoMetis.TypeKit.ValueObjects.IValue`1` / `IValidatedValue`3` with `GetTypeByMetadataName` and compares `OriginalDefinition` with `SymbolEqualityComparer`. If `CodoMetis.TypeKit` is absent, nothing is registered | -| Analyzer, `[RequireCustomInitialization]` | The attribute symbol is resolved the same way | -| Analyzer, CMTK0002 | The compilation references an assembly whose name is exactly `CodoMetis.TypeKit.Generators`, ignoring case as assembly names do. That is the package's own id, which a consumer cannot rename, and a prefix or suffix match never counts. The assembly is not strong-named, so the name is the part of its identity to compare; the version must not be | -| EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` (§4). There is no type scan and no assembly filter | -| OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata (§7). There is no referenced-assembly walk | - -Run-time code that holds only a `Type` reads the attribute rather than calling `GetInterfaces()` -(decision 17). Its type arguments are constrained to `IValueObject` and -`IValueObjectMaterializer`, so the interface still decides what a value object is; the -attribute is how a type says so after trimming, which removes an interface nothing uses and keeps -custom attributes. - -## 4. EF Core - -- **Materializer.** An interface in `CodoMetis.TypeKit.ValueObjects`: - `IValueObjectMaterializer { static abstract TSelf Materialize(T value); }`. The aspect - implements it **explicitly**, so it is invisible on the type's public surface and reachable only - through a constrained generic. Documented contract: *skips validation; for values this - application wrote itself; never call it on input.* CMTK0004 enforces who may call it. -- **One pair of conversions.** `ValueObjectConverter : ValueConverter`, public so a - property can also name it explicitly. Its expressions call its own public static - `ProviderValue` and `Materialize`: an expression tree cannot call a static abstract member - directly (CS8927), and EF's compiled model writes the expressions out as C# in the application's - assembly, where an internal helper did not compile (§11). `UseTypeKit()` composes a plain - `ValueConverter` over the same expressions, the type the compiled model recreates, because a - precompiled query casts a property's converter to its design-time type. -- **No per-type comparer.** A `readonly record struct` already has value equality, and EF's default - comparer uses it. A test pins that it agrees with comparing `.Value`. -- **Application (decided 2026-09-27, spikes/EfMapping).** `optionsBuilder.UseTypeKit()` registers - an options extension. The name is deliberately not value-object specific, so a later `Option` - column mapping can join it. It adds an `IRelationalTypeMappingSourcePlugin` that answers any - `IValueObject` with the provider's mapping for `T` and the converter composed onto it. - EF consults it per CLR type wherever it maps one: property discovery, keys, foreign keys, - primitive-collection elements, query parameters. The lookup's facets are passed on. The plugin - composes the JSON reader/writer itself, with both types known through the attribute's visitor: - EF's own composition uses `MakeGenericType`, which it refuses under Native AOT, where a precompiled - query still maps a value-object parameter at run time. - - Not a replaced `IValueConverterSelector`: it maps the same, but a second library replacing the - selector (as strongly-typed-id guides recommend) would take it away, and the resulting model - error does not name the cause. Plugins are additive. This was first decided the other way on - an unmeasured assumption, and reversed by the spike the same day. - - Not a pre-convention: `Properties()` needs the list of types up front, which means a scan. - - An application that builds EF's internal service provider calls - `AddEntityFrameworkTypeKit()` on that service collection instead. -- **Translators.** `ValueObjectMemberTranslatorPlugin` / `ValueObjectMethodCallTranslatorPlugin` - are registered by the same options extension, so a consumer wires nothing else. A column - operand is **re-typed** as the wrapped type, keeping the column's store type, rather than cast: - `Convert` produced `o."Code"::text = 'ABC'`, which PostgreSQL discards but which is not a no-op - everywhere (on SQL Server, a cast to `nvarchar(max)` can stop an index seek). Any other operand - is still converted. The method translator acts only on a method whose signature is the unwrap, - from a value object (or its `Nullable`) to what it wraps: the attribute is public, and a - hand-marked `Length(this ProbeCode)` was translated as the column (`o."Code" = '3'`). Any other - method carrying it is left to EF, which refuses the call. - - **The element of a primitive collection is converted, not re-typed (decided 2026-09-28).** EF - infers a collection's element mapping from the columns that read its table expression (`unnest`, - `json_each`, `OPENJSON`, `VALUES`), and a re-typed element column handed it the wrapped type's - mapping for a collection of value objects: `o.Tags.Any(t => t.Value == "x")` and - `ids.Any(i => i.Value == o.Id.Value)` threw during translation on PostgreSQL and SQLite. EF 10's - `ColumnExpression` no longer knows its table, so the element column is recognised by the name - every relational provider gives it, `value`; the name only chooses between two correct - translations, and a table column named `Value` gets the cast (`t.value::text = 'x'`). -- **Keys over an integer are generated on add (decided 2026-09-28, decision 23).** EF's value - generation convention reads the property's CLR type, so a value-object key was never generated: an - entity added without one was stored as 0, and switching an `int` key to a value object dropped its - identity column. A convention plugin (first among the model-finalizing conventions, so a provider's - identity strategy sees it) marks a single-column, non-foreign key over `int`, `long` or `short` as - generated on add, at convention precedence. A key over a `Guid` is left alone: the application - assigns `OrderId.New()`, and a generated key that is already set makes EF take a new entity reached - through a navigation for an existing one. -- **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` - (`Properties()`) matches the property's CLR type; the README says to configure the value - object (`Properties()`). Measured 2026-09-28 for precision, maximum length, column type and - an enum conversion. -- **Stored JSON (decided 2026-09-27).** The generated JSON converter applies `Create` (§5), which - is right for input and wrong for JSON the application stored itself (an event store, a document - column): a rule added later would make old documents unreadable. `StoredJsonConverterFactory`, in - the base package because it is a System.Text.Json concern, is the opt-in for such a store's - `JsonSerializerOptions`. It hands out the generated converter in a mode only it can create - (a private constructor), so stored documents are read in exactly the format they were written - in, dictionary keys included. SECURITY.md lists it beside the EF satellite as the two - validation-free paths. -- **How far the translation is unique, measured 2026-09-27** - ([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)): - - `.Value` in a query predicate fails in Vogen 8.0.7 and in plain EF Core 10. Thinktecture - 10.5.0 has no public `.Value`. - - Querying the underlying value through a **conversion-operator cast** works in plain EF Core - 10, and that is how Thinktecture users get it. - - README claim: "`.Value` works in LINQ as it does in memory". Never "the only library that can - query by the underlying value". - -## 5. Aspects - -- **Aspect-class count.** Ten classes. The Metalama Open Source edition (MIT) has no aspect-class - limit (pricing page, 2026-09-26). The unlicensed CI build confirms it (phase 6). -- **Entry points.** `ValueObjectAspect` introduces three private helpers on every value - object, and they are the only way the JSON, parsing and type-converter aspects create an - instance: `__FromJson` (a refusal throws `JsonException`), `__FromText` (`FormatException`) and - `__TryFromText` (`false`). A plain value object constructs directly; a validated one calls - `Create` through `GeneratedFactories` in the base package. The exception is the explicit - `IValueObjectMaterializer.Materialize`, by contract. `MinValue`/`MaxValue` are only generated for - a plain value object. -- **Declarations that cannot be generated** are errors, so no type is left half-generated: - CMTK1000/1001/1002 (not `partial`, not a record, a struct not `readonly`), CMTK1003 (more than one - marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a - generic value object, one that derives from another value object, a wrapped type that is not - a class, struct or enum, or one that is a value object: itself, one that reaches it again, or - any other, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares - equal only to its own type, which is not value equality), CMTK1007 (the name of the - `GetValue`/`ValueOrNull` class is taken, see below), CMTK1008 (a comparison member beside the - seam, see below), CMTK1009 (a hand-written constructor, see below). -- **No hand-written constructor (decided 2026-09-27).** A value object's one constructor is the - generated private one, and a hand-written instance constructor is CMTK1009. The implementation - aspect answers it before introducing its own, since it reads only its target: `Constructors` minus - the implicitly declared ones, measured to catch every form (the generated one's signature, any - other, one chained to the generated one, a struct's `X()`, a record's copy constructor, a - positional record's parameter list) and to leave the compiler's own and a static constructor - alone. The type's own members can still call the private constructor, as `Create` must: that is - the one trusted seam into a validated value object, stated on `IValidatedValue` and in SECURITY.md. -- **`Revalidate()` (decided 2026-09-28)** on a validated value object returns `Create(Value)`: today's - rules applied to an instance that was rebuilt without them, by the EF satellite or - `StoredJsonConverterFactory`. It is how an application finds the stored values a rule added later - refuses, which the validation-free read path otherwise hides by design. It returns what `Create` - returns, normalisation included, and a type that declares its own keeps it. -- **The companion class** holding `GetValue()`/`ValueOrNull()` sits at namespace level, as - extension methods must, and is named after the whole nesting chain: `Order.Id` gets - `OrderIdExtensions`. Named after the value object alone, `Order.Id` and `Customer.Id` both asked - for `IdExtensions` and Metalama crashed (LAMA0001). A name that a declared type or another value - object's companion already has is CMTK1007, naming it. -- **NodaTime.** Only `ValueObjectJsonAspect` handles NodaTime types, and it must not refer to - NodaTime at compile time, which would force NodaTime on every consumer. It resolves the types - with `TypeFactory.TryGetType` from the consumer's compilation, compares them by symbol, and uses - NodaTime's converters (values and dictionary keys) only when - NodaTime.Serialization.SystemTextJson is referenced too, and only where the options have no - converter of their own for the type, which wins as it does for the wrapped type. Parsing reaches - NodaTime types through their `[TypeConverter]` (`TypeDescriptor.RegisterType` and - `GetConverterFromRegisteredType`, the trim-safe lookup). -- **Explicit `IParsable`.** `bool` and `char` implement their parsing interfaces explicitly, so the - generated code calls the wrapped type's `TryParse` through `GeneratedParsing`, whose constrained - type parameters reach an explicit implementation. `Parse` is `TryParse` and a throw (below). -- **Parsing culture (decided 2026-09-27).** In the generated `Parse`/`TryParse` a null - `IFormatProvider` means the invariant culture, unlike the BCL, because the generated `ToString()` - is invariant: `Parse(x.ToString(), null)` must round-trip, and with the current culture it parsed - "1.5" as 15 in de-DE. A provider that is given is used as given. The type converter already did - this. -- **Formatting culture (decided 2026-09-27),** the counterpart: a null provider means the invariant - culture in the generated `ToString(format, provider)`, both `TryFormat`s and `IConvertible`. - Interpolation, `string.Format` and `Convert.ToString` pass null, and with the current culture - `$"{amount}"` was "1,5" in de-DE while `amount.ToString()` was "1.5". Display text in a culture - passes it: `string.Create(culture, $"{amount}")`. -- **The wrapped value's JSON contract** comes from `GeneratedJson.TypeInfo(options, builtIn)` - (decided 2026-09-27, §11): the options' own contract when their resolver has one, so a host's - number handling and converters apply, and otherwise made in the serializer's order (a converter on - the options, the type's `[JsonConverter]`, the built-in converter the aspect names by its property - type). A source-generated context never sees what a value object wraps, and serializing through - the options needs reflection. A dictionary key goes through the same contract's converter. -- **The bytes are the wrapped type's (decided 2026-09-28, decision 26).** Every wrapped-type family - writes and reads its values and keys through `GeneratedJsonPlan`, the converter the options have - for `T` or else the built-in (or NodaTime's) one: exactly what the serializer writes for a `T`, so a - host's converter, number handling, enum converter and `DictionaryKeyPolicy` apply, and swapping a - primitive for a value object changes no JSON. The earlier per-family readers and writers wrote - `TimeOnly` as `"13:45:30.0000000"` and `DateTime` keys with seven fractional digits, ignored a - host's converter for `Guid`, `DateTime` and integer keys, and skipped the key policy. - `JsonParityTests` compares a value object with its wrapped type for 14 families under five option - sets, as a value, a property and a key. -- **The wrapped value is written and read by its converter directly** (decided 2026-09-28), wherever - that is what the serializer would do: a value converter, and, for a number type only, no number - handling that changes the output (`WriteAsString`, named literals) or, for a string token, the - input. Otherwise through the serializer, which alone applies number handling. A nested - `JsonSerializer.Serialize`/`Deserialize` per value cost 35–40% (measured). The plan is kept per - converter instance for the options it last saw; a static plan per wrapped type cost 8% on writes. - The enum's built-in converter is made once per plan: made per call it allocated 4.7 KB per value. -- **Formatting and comparison never box** (decided 2026-09-28). The wrapped type's `ToString`, - `TryFormat` and UTF-8 `TryFormat` are called through `GeneratedFormatting`, a constrained generic - call that also reaches an explicit implementation, and comparison through `Comparer.Default`, - which the JIT specialises per type and, for an enum, compares the underlying values. Casting the - value to the interface boxed it; an optimising JIT hid that for some types and not for others - (enums), so the allocation tests run on a Debug build, where it never does. -- **JSON reads never parse by rules of their own.** A value and a key are read by the plan's - converter, which accepts exactly what the serializer accepts for the wrapped type. A `Parse` in - the generated code once let `FormatException` escape, which a minimal API answers with 500 rather - than 400, and read number keys with `NumberStyles.Any` ("1,000" as 1000). -- **What the wrapped type cannot read is refused without quoting it (decided 2026-09-28, decision - 27).** The BCL's and NodaTime's parse messages quote the input ("The input string 'SECRET' was not - in a correct format."), and a malformed NodaTime JSON value carried it in an inner exception. A - generated `Parse` is the wrapped type's `TryParse` and a `FormatException` naming the value object - and the wrapped type; where there is no `TryParse` (a static `Parse`, a string constructor, a type - converter) the exception is replaced, not wrapped; a JSON read throws `JsonException` the same way. - An overflow is a `FormatException` too. `UnreadableInputTests` feeds a secret through every strategy - and entry point and searches the whole exception chain. Out of reach: `JsonException.Path`, which - the serializer fills with an unreadable dictionary key, and ASP.NET's own binding messages. -- **`Uri` parses relative or absolute (decided 2026-09-28)**, as JSON and `UriTypeConverter` read it: - `new Uri(s)` made "/orders/7" a `file://` URI on Unix and threw for "orders/7". -- **A hand-written `ToString()` is a seam (decided 2026-09-28, decision 28).** It was replaced - silently, so a value object that hid a secret printed it. It is kept, and then no formatting - interface (`IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`) is generated, since - interpolation would reach those first; the type converter and JSON still write the wrapped value. -- **More declarations refused (decided 2026-09-28).** Each failed as a Metalama error naming nothing - the user wrote, or not at all: - - CMTK1010: a value object declared in more than one `partial` part. Metalama 2026.1 (and 2027.0 - preview) writes the override of the record's synthesized `ToString` into every part (CS0111 as - LAMA0611); no other advice overrides it (LAMA0041, LAMA0509, LAMA0500 measured). A part a source - generator adds is not in `Sources` and does not count. The repro for an upstream report is in the - phase 10 notes below. - - CMTK1011: a hand-written member or attribute the generators introduce (`From`, `Value`, - `Parse`/`TryParse`, `MinValue`/`MaxValue`, `[JsonConverter]`, `[TypeConverter]`, the interfaces), - checked before anything is introduced, as CMTK1009 is; the seams (`TryFrom`, `FromKnownGood`, - `Revalidate`, `CompareTo(TSelf)`, `ToString()`) stay allowed. Some of these were introduced with - `OverrideStrategy.Ignore` and kept silently: a hand-written `Parse` became the binding entry - point. `Create` implemented explicitly is CMTK1011 too, since the generated code calls - `{Type}.Create`. Since phase 11 also a hand-written `Equals(TSelf)`/`GetHashCode()`/ - `IEquatable.Equals`, `PrintMembers` without `ToString()`, an explicit implementation of an - interface the generators implement (CMTK1008 for the comparison interfaces), and a declared - `IConvertible`. - - `Create` is declared once, public: an explicit one beside it is CMTK1011, since a generic - `T.Create` would reach a second rule set. A base record's members count as the value object's - 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. - - CMTK1012: state besides the wrapped value, an instance field, auto-property, `required` member or - field-like event, declared or inherited. JSON, parsing and the materializer carried the wrapped - value alone while equality compared the rest. Computed and static members stay allowed. In a - referenced assembly a base's private fields are invisible, so a writable property counts there. - - CMTK1005 adds a generic wrapped type, tuples included (its equality is not value equality: a - `List` compares by reference), a value object named `Value` (CS0542), and a `file`-local one - (LAMA0001, which failed the whole project). -- **A `DateTime` is UTC on the wire (decided 2026-09-27).** Written and read, value and key, through - `GeneratedJson.AsUtc`: a `Local` value converts, and an `Unspecified` one (an offset-less string, a - column without a time zone) is taken as UTC with its digits kept. `ToUniversalTime()` read it as - server-local, so 12:00 became 10:00Z on a server at +02:00 and 12:00Z on CI. The guards switch the - process to a +05:30 zone, since CI runs in UTC. -- **`From(null)` throws** `ArgumentNullException` for a reference-type wrapped value, as - `Option.Some(null)` does. It wrapped a null that `Value` promises it never holds. -- **Enums** parse by name through `Enum.TryParse`, with a `FormatException` for an unknown name. - `Convert.ChangeType` cannot make an enum from a string, so the former `IConvertible` strategy - threw for every input while the type advertised `IParsable`. Nothing else that is convertible - lacks `ISpanParsable`, so that strategy is gone. -- **String comparison is ordinal**, so ordering agrees with the record's equality. Culture - comparison sorts "a" before "B" and ignores a zero-width space, and a `SortedSet` then dropped a - value a `HashSet` kept. -- **A nullable wrapped type is CMTK1005.** The markers' `notnull` constraint is only a warning - (CS8714), and `IValue` then failed inside the generated code as LAMA0611/0612. -- **A value object never wraps a value object (decided 2026-09-27).** CMTK1005, answered in the - fabric because it reads the wrapped types (see "An aspect never scans its namespace" below). - - **A cycle** (`sealed partial record SelfWrap : IValue`, or A wraps B wraps A) has no - finite form: the fallback JSON converter serializes the wrapped value through the options, - which is its own converter again, and a schema walk never ends. As a record class it compiled - without a word (spikes/OpenApiSchemas finding 16), as a struct it failed inside the generated - code (CS0523 as LAMA0611). The message names the chain: `it wraps itself (A -> B -> A)`. - - **Any other value object** (`Outer : IValue`) terminates, but its surface depended on - where the inner one was declared. Over one from a referenced project it got every interface; - over one from its own project it silently lacked `IParsable`, `IComparable`, `ISpanFormattable`, - `IConvertible`, `IMinMaxValue` and the type converter, and sorting it threw: the aspects of one - layer do not see what their sibling instances introduce (measured 2026-09-27). The message - names what to wrap instead (`wrap 'Guid' instead`); a validated value object applies the inner - one's rules in its `Create`. Refused rather than made to work: a surface that follows the - project layout is the silent failure this repo exists to prevent, and lifting a refusal later - breaks nobody, while adding one after 1.0.0 would. - - The satellites keep their own refusal (§7): a value object declared by hand, with the - interfaces and `GeneratedValueObjectAttribute<,>` the satellites read (§3), can still wrap itself. -- **Every JSON key goes through the wrapped type's own converter**, which knows the type's key - format where it has one (an enum by name, a `Uri` as its text) and throws `NotSupportedException` - where it has none. Writing the serialized value as the property name gave a `Uri` key quotes inside - its quotes and an enum key its number, and neither read back. -- **A record class sorts null first.** Its generated `CompareTo` and comparison operators take a - nullable operand, as `IComparable.CompareTo(T?)` and the record's own `==` do, and treat null - as smallest, where they threw `NullReferenceException`. -- **Ordering follows the wrapped type.** `CompareTo` delegates to the wrapped type's, ordinal for a - string, so it agrees with equality exactly where the wrapped type's own does. A custom wrapped - type whose `CompareTo` returns 0 for values its `Equals` tells apart makes a `SortedSet` drop one, - of the value object and of the wrapped type alike (measured 2026-09-27). That is the wrapped - type's contract to keep, and the marker interfaces' documentation says so. -- **One comparison seam (decided 2026-09-27).** A hand-written `CompareTo(TSelf)` is kept, and the - object overload, the operators and the interfaces are derived from it, as `TryFrom` is for the - factories. Any other hand-written comparison member is CMTK1008: an operator failed the aspect - (LAMA0500, naming no fix), and an object overload was kept silently beside a generated generic - one that need not agree with it. -- **An aspect never scans its namespace (decided 2026-09-27, - [spikes/ConcurrentNamespaceTypes](../spikes/ConcurrentNamespaceTypes/README.md)).** What owns - a companion name is answered in the fabric, before any introduction, and reaches the aspect - through its constructor and the aspect state. Anything that has to look beyond the aspect's own - target goes the same way. -- **Never look up the code model by name (decided 2026-09-28, same spike).** Compile-time code - finds a type or member by enumerating a collection and comparing names: no `OfName`, no - `OfExactSignature`/`OfCompatibleSignature`, no string indexer on `Fields`/`Properties`/`Events`. - Metalama builds a collection's by-name index lazily and without a lock, and runs the fabric's - factory, like the instances of one aspect layer, in parallel on one code model. An `OfName` that - overlapped another caller completing the collection returned nothing for a declared - `TakenNameExtensions`, and the build compiled a second one (CS0260) or Metalama failed the aspect - (LAMA0531). In the fabric that missed CMTK1007 in 13 of 300 builds; the move there on 2026-09-27 - had measured 0 in 48 by chance and blamed sibling introductions, which was wrong. With concurrent - build off: 0 in 200. Enumerating, which takes the collection's lock: 0 in 600, and 4 in 150 - again with it reverted. The race has no deterministic test, so `CodeModelLookupTests` - (conventions) fails on any such lookup in `.Generators`. -- **Metalama 2026.1.** Aspect state uses `IDurableRef`, which exists in 2026.1. `[Durable]` on the - `_value` template placeholder is 2027.0-only and stays out until the upgrade (decision 4). Build - each aspect on 2026.1 as it lands, and use no 2027.0-only API. -- **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`. Open point: ordering against a - consumer's own aspects (fabric spike D). -- **Not in scope:** a `Try`/`Unit` type (§9 non-goals; an exception enters a result through - `Result.Catch`, phase 13), clock-type analyzers, and decimal JSON converters (no aspect needs them, - checked in phase 3). The text guards joined phase 13 on 2026-10-13, as string extensions in the - base package. -- **Public aspects (decided 2026-10-13, decision 43).** The value-object aspects are internal: the - fabric applies them, and their surface is what they generate. An aspect a consumer applies by hand - may be public when the aspect is the feature, with the attribute and its diagnostics as its - surface. None ships yet: the first candidate, a parameterless-constructor guarantee, went to the - analyzer and a code fix (phase 13), because its value is the error, which has to appear in a - project that never compiles with Metalama. - -## 6. Test strategy - -1. **Behaviour.** Every public member of `Option`/`Result` and every generated member has a - behaviour test. Generated members are tested against woven probe types, never against code - written by hand in the test assembly. -2. **Generated surface.** A small tool reflects over the woven probe types. For each value object - it renders the public members, implemented interfaces and attributes as sorted text, which is - committed and asserted. An aspect change that adds or loses a member then shows up in review - instead of shipping silently. Once 1.0.0 ships, package validation against the last release - guards the hand-written surface too. -3. **Generated SQL.** `ToQueryString()` snapshots for `.Value`, `GetValue`, `ValueOrNull`, - `StartsWith`, equality, and `Contains` over a list of ids. -4. **OpenAPI.** A probe host's emitted document, asserted per shape against a control that uses - the wrapped type directly, so the tests follow what ASP.NET publishes for that type (§7). -5. **Performance.** Allocation tests hold the generated members to what the wrapped type allocates, - and `benchmarks/` measures time against the wrapped types. The benchmarks are not a gate: they are - run before a release and when a template changes. -6. **README and docs samples** (2026-09-28; the `docs/` pages 2026-10-13, decision 45). Every C# - block of the READMEs and of the topic pages in `docs/` is a region in - `test/CodoMetis.TypeKit.Samples`, which references the shipping projects as a consumer does, with - the analyzer on. `ReadmeSampleTests` requires each block to equal exactly one region and each region - to be shown, so a sample that stops compiling fails the build and an edit on one side fails the test. - A docs page is discovered by being in `docs/`, and the root README's "Documentation" table has to - link it. - An output stated in a comment is asserted against the README's own comment, found by the expression - the test evaluates. A pre-release audit had found samples that did not compile (a bare fault as a - `Result`, `quantity.Value;` as a statement, an ellipsis as a lambda body), CMTK0006 on the - EF entity sample, and SQL the query does not produce; the consumer smoke test had found the first - once before (§9). Seeded: a README or sample edited alone, the bare fault back in both, the committed - READMEs, a changed SQL, JSON or string output, an opt-out on a compiled block or without a reason, an - unlabelled fence, and either parser finding nothing each fail their test. - -## 7. OpenAPI - -**Decided 2026-09-27, measured in [spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)** -with Microsoft.AspNetCore.OpenApi 10.0.12. An earlier measurement had found that a schema -transformer never reached a type that only appears as a property, and repaired such types in a -document transformer. It does not hold in 10.0.12: one schema transformer reaches every JSON -position, and the change lands in the hoisted component. - -- **The schema is the wrapped type's, as ASP.NET publishes it for this host**, asked of ASP.NET - through `context.GetOrCreateSchemaAsync(typeof(T))`. It therefore follows the host's JSON options - (number handling, enum converter) exactly as the generated converter does, and the document's - other transformers (a NodaTime transformer describes a value object wrapping `Instant` too). No - format table of our own. -- **Keywords are filled in, never the object replaced**: the transformer receives the instance ASP.NET - hoists into the value object's own component, so `OrderId` stays a named component. What the - value object's schema already says is kept. ASP.NET's `Metadata` (the reference id) and the - schema-identity keywords are never copied; copying `Metadata` made every use a reference to the - wrapped type's component. A keyword is assigned only when the wrapped schema has one. -- **Containers**: ASP.NET drops `items`/`additionalProperties` for a converter-backed element before - any transformer runs; the value object's own schema is put back, only where missing. -- **Parameters** bound through the generated `TryParse` reach the transformer as `string`, ASP.NET's - placeholder for any parsable type, which the wrapped type's schema replaces. Minimal APIs name the - value object in the parameter's `Type`, MVC in `ModelMetadata.ModelType`; the parameter descriptor - names the container for an MVC `[FromQuery]` object and is not used. Each parameter then publishes - exactly what a parameter of the wrapped type publishes. -- **A value object that reaches itself** through what it wraps is an `InvalidOperationException` - naming the chain, not a stack overflow. -- **Source-generated JSON** (§11): ASP.NET builds the wrapped type's schema from the host's JSON - contract for it, and a source-generated context has none for a type the host never serializes. - The transformer then refuses with an `InvalidOperationException` naming the value object and the - `[JsonSerializable(typeof(Guid))]` to add, rather than the serializer's message about a Guid. A - resolver of our own cannot be added: the host's options are read-only by then. -- **Component names (decided 2026-09-28, decision 24).** ASP.NET names a component after the type's - simple name, so `Shop.Id` and `Stock.Id` shared one, and a stock id wrapping an `int` was documented - as a uuid. `AddTypeKit()` wraps `CreateSchemaReferenceId`: a nested value object that got ASP.NET's - default name is named after its nesting chain (`ShopId`, as its companion class is `ShopIdExtensions`); - a name the host's own delegate chose, or `null` for an inlined value object, is kept. -- **Known gaps, documented rather than fixed:** a validation attribute on a value-object property - lands in the shared component, as ASP.NET does for any referenced schema (rules belong in `Create`); - a property absent from a request body stays `default`, since System.Text.Json calls no converter - for it (`required`, `RespectRequiredConstructorParameters`); a minimal API answers a refused body - with an empty 400; MVC's own binding message quotes the input, as it does for a `Guid`. -- **ASP0020** (decision 12): a minimal-API route parameter whose value object is declared in the - same project fails the build, because the route analyzer reads the source before Metalama weaves - `IParsable` in. Binding is correct at run time and in the request delegate generator. A - `DiagnosticSuppressor` has no effect under Metalama's compiler, and Metalama's own suppression is - scoped to the aspect's targets, so the package README documents the pragma instead. - -Vogen 8.0.7 generates a schema transformer for Microsoft.AspNetCore.OpenApi too, from a table of -wrapped types (checked in its source at the tag, 2026-09-28; the 2026-09-27 note here said -Swashbuckle only). The README describes what the package does and claims nothing about other -libraries. - -## 8. Decisions - -1. **License: MIT** (2026-09-26). -2. **Name: `CodoMetis.TypeKit`** (2026-09-27), replacing `CodoMetis.ValueObjects`, which was too - narrow. Package map in §1. -3. **Analyzer ids: `CMTK`** (2026-09-27). Ids are public contract: a rule keeps its id even if it - later moves to another package. -4. **Metalama 2026.1.x (latest stable)** (2026-09-26), not the 2027.0 preview. - - Aspect state keeps `IDurableRef`, which exists in 2026.1. - - `[Durable]` on the `_value` template placeholder is omitted, because it is 2027.0-only, and - is added back on upgrade. - - A 2026.1-built package works in a 2027.0 consumer (fabric spike finding 11). - - Once 2027.0 is stable, upgrading is a planned task: MIT maintenance covers only the latest - `YYYY.N`. - - **Metalama is the generator technology for this project**, not a candidate for replacement. -5. **Option/Result** (2026-09-27): pure `Option`/`Result`, not a LanguageExt-style framework. - They ship in the base package. No `Try`/`Unit` (§9). -6. **`Result` shape** (2026-09-27): `Result` (value first), `readonly record struct`, - private fields, non-positional (§9). -7. **Analyzer as its own package** (2026-09-27), reaching consumers through the base package (§10). -8. **An uninitialized `Result` throws** (2026-09-27) instead of reporting `default(TError)` (§9). - -9. **EF mapping through an additive `IRelationalTypeMappingSourcePlugin`** (2026-09-27), not a - replaced `IValueConverterSelector` or a pre-convention scan (§4, spikes/EfMapping). -10. **Stored JSON through `StoredJsonConverterFactory`** in the base package (2026-09-27, §4). -11. **OpenAPI through one schema transformer** that fills a value object's schema in from the - wrapped type's, as ASP.NET publishes it (2026-09-27, §7, spikes/OpenApiSchemas), not a document - transformer and not a format table. -12. **ASP0020 is documented, not suppressed** (2026-09-27, §7): the package cannot suppress it, and - a blanket suppression would hide the check for every other type. -13. **The first release's rule set is CMTK0001–CMTK0009** (2026-09-28, §10; CMTK0009 added the same - day, at Warning, for the calls that hand out a default). CMTK0004 ships at Error - in 1.0.0, the one release where a new Error rule needs no major version; CMTK0003, CMTK0005 and - CMTK0006 at Warning, CMTK0006 once its fix was measured; CMTK0007 at Info, reporting only a - value that comes straight from a parameter. -15. **`Option`/`Result` are not wire types** (2026-09-27, §9): a `[JsonConverter]` on every exported - struct of the base package throws `NotSupportedException` in both directions. Not a lossless - converter, and not an analyzer. -16. **Native AOT wherever the platform allows it** (2026-09-27, §11): the run-time packages build with - `IsAotCompatible`, the woven code is published with Native AOT in the smoke test, and a path that - needs reflection or dynamic code is rewritten rather than annotated or suppressed. EF Core itself - stays experimental under Native AOT; the satellite adds nothing to what EF requires. -17. **Run-time discovery reads `GeneratedValueObjectAttribute`** (2026-09-27, §3, §11), - which the generators put on every value object, not `GetInterfaces()`. Measured under Native AOT: - the interface was gone from every value object reached through a property, and the OpenAPI - satellite described each as `{}`. The attribute's constraints are the interfaces, so what a value - object is has not changed; its `Accept` hands the two types to a visitor as type arguments, which - is how the EF satellite makes a converter without `MakeGenericType`. -18. **One name per concept** (2026-09-27, before the first release, when renaming still costs nothing). - - What a value object holds is *wrapped*: `GeneratedValueObjectAttribute.WrappedType` and - `TranslatedAsWrappedValueAttribute`, not "underlying", and not `ValueType`, which in .NET means - a struct and here returned `typeof(string)` for a string-backed value object. - - A value object without rules is *plain*: `IPlainValueObject`, which has `From`. It was - `IValueWrapper`, which named what every value object does. - - The value object's own type parameter is `TSelf` on the self-referencing contracts, as in - `IParsable`, and `TValueObject` where a helper or attribute names one. Never `TValue`, - which `KnownGood` used for the value object while "value" means the wrapped value everywhere - else. - - Delegate parameters: `onSome`/`onNone`, `onSuccess`/`onError`, `selector`, `predicate`, - `action`, as LINQ names them. They were `fnSome`, `map`, `fn`, `check` and `defaultProvider`. - - Namespaces follow who uses a type (§1). The helpers only generated code calls moved to - `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense, and `Accepted` and `KnownGood` - became one `GeneratedFactories` whose methods name the exception each entry point throws. - - Internally: `ValueObjectAspect` (was the implementation aspect), `ValueObjectContractAspect`, - `ValueObjectCompanionAspect`, `ValueObjectKind.Plain`. -19. **Concise, not implicit** (2026-09-27, §9). Markers where a target type exists, explicit type - arguments where none does. - - One word for success: `Result.Success(...)` beside `Result<…>.Success(...)`, the `Success` - markers, `ResultState.Success` and `onSuccess`, as `Option.Some` sits beside `Option`. - `Result.Ok` was the one place that said it differently. - - `Option.None()` returns a marker that converts to any `Option`; `Option.None()` stays for - `var`. The analyzer forbids `default(Option)`, so every None needed its type argument. - - `MapError` on both shapes, for crossing layers, which needed - `Match>(x => x, e => Result.Error(…))`; and `Bind(Func>)`, a - command after a query, which replaces the collapsing `Match(_ => Result.Ok(), e => e)`, a - conversion wearing `Match`'s name. - - `Option.ToResult(error)` keeps the value (`Result`); it returned `Result`, - dropping it. `Coalesce` is `Or`, beside `OrDefault()` and `OrNull()`. - - Not added: an implicit `T` → `Option`, since a null would throw exactly where a reader - expects `None`; a bare error → `Result` (the ambiguity above); a shipped - `using static`. A lambda that returns a bare value and `Result.Error(...)` still needs its type - argument, because C# infers a lambda's return type from its body alone; the README says so. - -20. **net10.0 only** (2026-09-28). The satellites cannot go lower (EF Core 10 and - Microsoft.AspNetCore.OpenApi 10 require it), the contracts use static abstract interface members, - which rules out netstandard2.0, and .NET 8's support ends on 2026-11-10. -21. **The first release is 1.0.0** (2026-09-28), not 0.1.0. What the reasoning above says of "the - first release" (CMTK0004 at Error, refusals that can be lifted later) holds for 1.0.0 unchanged. - From then on the public API, the generated surface and the rule ids and severities follow - Semantic Versioning, and package validation compares every pack with 1.0.0. -22. **Registration extensions live in the framework's namespaces** (2026-09-28): `UseTypeKit()` in - `Microsoft.EntityFrameworkCore`, `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()` in - `Microsoft.Extensions.DependencyInjection`, as EF's providers and `AddOpenApi` do, so the one line a - host adds needs no `using` (the docs audit found every setup sample missing one). Types stay in - `CodoMetis.TypeKit.*`. Moving them after 1.0.0 would break every host. -23. **A value-object key over an integer is generated on add; one over a `Guid` is assigned** - (2026-09-28, §4). Parity with the wrapped key where only the database can make one, and the - application's `OrderId.New()` where it can. -24. **Nested value objects are named after their nesting chain in the OpenAPI document** - (2026-09-28, §7), as their companion classes are. -25. **The analyzer's Roslyn floor stays at 5.0.0** (2026-09-28), the compiler of the 10.0.1xx SDK band, - which is serviced for all of .NET 10 and is what Linux distributions build. Measured: built on - 5.9.0 (SDK 10.0.4xx) and run by the 5.0.0 compiler, the analyzer is not loaded (CS9057, a warning) - and `default(Option)` compiles. `AnalyzerPackagingTests` reads the packed analyzer's - references. The build-time `Microsoft.CodeAnalysis.Analyzers` follows the SDK (5.9.0). -26. **A value object's JSON is its wrapped type's, byte for byte** (2026-09-28, §5), under whatever - options the application uses: swapping a primitive for a value object changes no wire format. -27. **No refusal quotes the input** (2026-09-28, §5), not even one the wrapped type raises: every - generated `Parse`, type converter and JSON read replaces the wrapped type's exception with one that - names the value object and the wrapped type. -28. **Seams are declared, everything else refused** (2026-09-28, §5): `TryFrom`, `FromKnownGood`, - `Revalidate`, `CompareTo(TSelf)` and `ToString()` may be written by hand and are kept; any other - member or attribute the generators introduce is CMTK1011, never kept silently. Since phase 11 so - is an explicit implementation of an interface they implement, a hand-written equality, and - `PrintMembers` without `ToString()`, declared or inherited from a base record, whose `sealed` - `ToString()` is the seam; state besides the wrapped value is CMTK1012. -29. **Only `Result` converts to `bool`** (2026-09-28, §9). On a valued result the conversion - said whether the operation succeeded where a reader expects the value: `if (await - IsEmailTakenAsync(email))` over `Result` took the branch for `Success(false)`. The - same reason keeps a bare error from converting into one. Match it, or compare `State`. -30. **Asynchronous callbacks return `Task`** (2026-09-28, §9). `TapErrorAsync(Func)` sits - beside `Action`, as `TapAsync` already did. It ships in 1.0 because adding it later - rebinds existing calls. No `ValueTask` overloads: beside the `Task` ones every `async` lambda - becomes ambiguous (CS0121, measured). -31. **No extension on `object` or on an unconstrained `T`** (2026-09-28, §9): it is offered on every - expression of every file that imports the namespace, and collides with anyone else's. - `No_extension_member_extends_every_type` holds it. -32. **A value object holds its wrapped value alone, and its equality is that value's** (2026-09-28, - §5). Everything generated (JSON, parsing, the type converter, the EF column, comparison) carries - the wrapped value, so other state (CMTK1012), a hand-written equality or an explicit - implementation beside a generated member (CMTK1011, CMTK1008) would make two views of one value - disagree. Normalise in `Create` instead. -33. **The analyzers run on the code Metalama transformed** (2026-09-28, §10), through a - `MetalamaTransformedCodeAnalyzer` item in the analyzer package's `buildTransitive` props. The - namespace covers every rule, a future one included. -34. **Errors are fixed within a major version; Warnings may learn** (2026-09-28, §10). A new Error, or - an Error that reports more, waits for a major version. A Warning or Info may report more forms in - a minor version. The CMTK0003, CMTK0008 and CMTK0009 gaps the second review found were closed - before 1.0.0 was tagged all the same, so the rules ship as complete as the review could make them. -35. **SQLite's autoincrement is the application's line, not the convention's** (2026-09-28, §4). EF's - SQLite provider decides by the CLR type, and the only other way in is an annotation typed by the - SQLite assembly: setting it means naming the provider or writing a foreign annotation into every - other provider's model. The README documents `UseAutoincrement()`; `MigrationSnapshotTests` fails - once EF fixes the check, which is when the note can go. -36. **A nullable value object admits null in the OpenAPI document wherever it appears** (2026-09-28, - §7): as a property, as a container's element (`oneOf: [null, component]`, ASP.NET's own form), - and inlined (null added to `type`, or `oneOf` around an inlined enum). -37. **A refusal's message is its landing page** (2026-09-28): an exception has no help link, so every - deliberate run-time refusal says what to do instead and ends with a link to its subsection of the - base README's "Why does this throw?", and every generator build error with one to the Build errors - table. The links are held to the README's headings by tests. Nothing of the input is added. - -38. **A `Result` becomes a response through an explicit `ToHttpResult()`, its faults mapped in the host** - (2026-10-11, phase 12, spikes/ResultHttp). The return type is ASP.NET's - `Results, FaultProblem>`, so ASP.NET describes the success and the Request Delegate - Generator needs nothing of ours; a handler returning the `Result` itself would document a `Result`. - The fault type is an **enum**: its members are what a mapping can be checked against when it is - registered, and what the document lists. A fault of another shape is `MapError`ed to one at the - endpoint. The extensions live beside the framework's (`ToHttpResult` in `Microsoft.AspNetCore.Http`, - `AddFaultResponses` in `Microsoft.Extensions.DependencyInjection`, decision 22). A missing mapping - stops the host from starting (a startup filter, which comes with the first `AddFaultResponses`), a - value no member names is refused rather than answered, and the README's mapping ends in a throwing - discard arm: the registration calls it for every member, so a missed member still fails at startup, - and without the arm C# reports CS8524. - -39. **A value object's fault does not become a validation error** (2026-10-11, phase 12, - spikes/ValidationFaults). ASP.NET Core 10's validation never sees a value that failed to bind, a - refused route or query value has lost its fault, and MVC already reports a refused body with the - converter's message. What remained, a minimal-API body, needs `ThrowOnBadRequest` for the whole - host, an exception handler in its pipeline, and the fault carried as data by a new public type in - the base package: too much host-wide change for one of four positions. Reopen it if ASP.NET hands - a binding failure to a hook without `ThrowOnBadRequest`. -40. **`CodoMetis.TypeKit.Testing` holds assertions that hand back the content** (2026-10-11, phase 12). - `ShouldBeSome()`, `ShouldBeSuccess()` and `ShouldBeError()` return the value or the error, so the - test compares it with the assertions it already uses, and the package depends on no test framework: - a failure is a `TypeKitAssertionException`, which every runner reports. A failure names the error - it found and never prints a value, as `Option` and `Result` do not; an uninitialized result fails - every assertion as uninitialized, since `default(TError)` would otherwise read as an expected - first-member fault. **No generators**: one that goes through `Create` is a single filter over the - test's own property-based library (`Gen.String.Where(s => Email.TryFrom(s).IsSome())` in CsCheck), - and shipping it would tie the package to one such library. The package is new in 1.5.0, so its - project clears the package-validation baseline while that is 1.0.0, a condition that lapses on its - own when the baseline moves. - -41. **A sensitive value object is one that declares `ToString()`; Serilog gets a satellite** (2026-10-11, - phase 12, spikes/SensitiveValues). The seam already masks every formatting and - Microsoft.Extensions.Logging path, so a marker would generate the one line a user writes, and is - not added. Serilog's `{@x}` read the public `Value` of every value object, masked or not: - `CodoMetis.TypeKit.Serilog` registers one destructuring policy, `Destructure.WithTypeKit()`, that - logs a value object as a scalar of its `ToString()`, recognised by `GeneratedValueObjectAttribute`. - Every value object is then a string in the event, as Microsoft.Extensions.Logging writes it, rather - than its wrapped value's JSON type: what it prints is the one rule, and the policy cannot tell a - masked `ToString()` from the generated one. Serilog's floor is 4.0.0, the first of the current - major, and the tests run on it. A classification on the type is advised against: the logging - generator refuses the method (LOGGEN035). - -Still open: - -14. **context7.json.** The sibling repos register one. Add it once the repo is public. - -42. **2.0.0 is the next release, and it bundles the 1.x candidates** (2026-10-12, phase 13). The - fault responses, `Patch` and the smart-enum family ship together in the major that already - waits for Metalama 2027.0 stable, decision 34's promotions and C# 15 unions. A 1.6.0 first was the - alternative. Every part is additive, so it would have been a minor version, but the fault mapping - would have been designed on enums with `FaultKind` attributes read by reflection, then rebuilt - once smart enums exist. The cost is time: nothing ships before the later of .NET 11's GA and - Metalama 2027.0 stable. -43. **Exceptions, type-argument constraints, a constructor guarantee and the text guards join - 2.0.0** (2026-10-13, phase 13). The goals were reopened and four candidates weighed. `Try` stays - out, since it is `Result` renamed, and `Result.Catch` takes its one job; `Unit` - stays out, since `Result` is the value-less shape. Constraints a type parameter cannot - declare (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`) ship as attributes and an analyzer - rule, not as an aspect, because the violation is at a use site that need not compile with - Metalama. `[EnsureParameterlessConstructor]` ships as an attribute, an analyzer rule and a code - fix, not as an inheritable aspect, because its value is the error and an introduced constructor - would demand `partial` of every implementer and be silent without Metalama. The written-text - guards ship as string extensions and a fault in the base package, with no value object and no - `.Text` package: the base package cannot declare one for the generators, a hand-written one is - what CMTK0002 refuses, a woven one in a package of its own costs a private generators reference, - a boundary exemption, a baseline override and a smoke consumer for one line per field, and the - type cannot be refined (CMTK1005) or told apart from another field of the same type (CMTK0008). - Public aspects are allowed in principle (§5) and none exists. Clock-type analyzers and - decimal JSON converters stay out. -44. **2.0.0 is `net10.0` on Metalama 2026.1; the version number names the API, never the runtime** - (2026-10-13, phase 13). Dropping a target framework is a break of its own, so a `net11.0`-only - 2.0.0 would have left every consumer on the .NET 10 LTS line (supported to November 2028) on - 1.5.x, without the fault responses, the closed sets or `Patch`, for a .NET 11 that is STS. The - breaks 2.0.0 actually has, decision 34's promotions and two rules at Error, are breaks on - `net10.0`, and none of its parts needs C# 15 unions. A `net11.0` target is additive and joins a - 2.x minor when a feature needs it; unions in the public surface would be two contracts under one - version, so they and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS. Metalama 2027.0 joins - the first release after it is stable, minor or major as the fabric measurement says. The - `2.0.0-preview.N` branch of 2026-10-12 becomes a preview of 2.0.0 itself from `main`; the .NET 11 - measurement is a 3.0.0 spike with no release attached. -45. **Topic pages in `docs/`, compiled like the READMEs; the plan in `design/`** (2026-10-13, phase - 13). The package READMEs stay the reference for each package, since nuget.org shows them and every - exception message and analyzer help link anchors into them. Guides, one topic each and written - around examples, are `docs/.md`, listed in the root README's "Documentation" table, which - `ReadmeSampleTests` reads: every page in `docs/` is compiled by being there (its samples are - `{file name}/{name}` in `test/CodoMetis.TypeKit.Samples/Docs`), must be linked from the table, - and the table links only pages that exist. The same code block is never in a README and a page, - since a block is compiled from exactly one region. A page's public-surface table (`option.md`, - `result.md`) is checked against the types by reflection, so a member added without a row fails. - The plan and the design notes moved to `design/`, so that `docs/` holds only what a consumer - reads and nothing there is exempt from compilation; `design/` is a solution folder. Eleven pages - came first; `merge-patches.md` and `closed-sets.md` joined them once the docs branch merged into - 2.0.0 (2026-10-13). The fault responses are in the ASP.NET Core and closed-set pages, no public - aspect is planned (decision 43 allows one, and none exists), and `migrating-to-2.md` lists what - builds or runs differently for 1.5 code. - -46. **A timeout caught by `Result.Catch` is the error** (2026-10-13, phase 13). Every overload let - an `OperationCanceledException` through, and `HttpClient` throws one over a `TimeoutException` - when its `Timeout` elapses, so `CatchAsync(() => client.GetAsync(…))` threw on the most common - failure of the call the README shows it with, and a sample test that - raced a 100 ms timeout against a refused port failed whenever the timeout won (measured). A - timeout is the call failing, not the caller cancelling: an `OperationCanceledException` whose - `InnerException` is a `TimeoutException` is caught, every other one still passes through. No - `CancellationToken` overload: the shape tells the two apart without one, and a token cancelled by - the caller still surfaces as cancellation. -47. **`FirstOrNone` on a query runs in the query** (2026-10-13, phase 13). The `IEnumerable` - overloads were the only ones, so `db.Orders.FirstOrNone(o => o.Id == id)` read the table and ran - the predicate in memory, with no warning (reproduced: no `WHERE`, no `LIMIT`). `IQueryable` - overloads take an expression and `Take(1)`, a break only for a predicate the provider cannot - translate, which now throws: a major's to make. `LastOrNone` and `LastOrError` on a query are - `[Obsolete(error: true)]`, as `Catch`'s task trap is, since a query has no last element until it - is ordered. EF's `FirstOrNoneAsync` lives in the satellite, in EF's namespace; under Native AOT - neither is precompiled (measured), which the EF README says with the call-site form to use. -48. **An intended break is suppressed alone; the baseline stays** (2026-10-13, phase 13). The plan - left the choice to the first break package validation reported, and it came with the fault - responses: `FaultProblem` takes `notnull` where it took `struct, Enum` (CP0021), so that a - closed set can be a fault type. Each intended break gets its own entry in the project's - `CompatibilitySuppressions.xml` (generated with `-p:ApiCompatGenerateSuppressionFile=true`, then - read before it is committed), and the 1.5.1 baseline keeps comparing every other member, so an - unintended break still fails the pack. Letting the baseline lapse for 2.0.0 would have passed one. -49. **Fault responses a client can act on** (2026-10-13, phase 13, spikes/FaultResponses). The five - parts ship as proposed, with these choices measured or made on the way: - - **A kind's status is fixed**: `NotFound` 404, `Conflict` 409, `Invalid` 422 (RFC 9110 - §15.5.21, a well-formed request a rule refuses), `Forbidden` 403, `Unavailable` 503. No - host-wide table: a mapping of the host's own, calling `FaultResponse.For(kind)` for the members - it keeps, is the escape hatch, as decision 38's switch is. `FaultKind` starts at 1, so a - `default` kind is refused where the set is mapped. - - **`Fault`'s fault is its `Reason`**, not its `Kind` as first proposed: a member's - `FaultKind` is its `Kind`, and `fault.Kind.Kind` would have read as a stutter. A blank detail is - refused; `null` says there is none. - - **A description is the `detail`**: RFC 9457 §3.1.4 has `detail` help the client correct the - problem, and §3.1.3 keeps `title` the same for every occurrence of a type, which a status's own - title already is. An occurrence's detail replaces the member's description. - - **`IFault` is non-generic**, beside `IFaultSet`, so the satellite reads a member's kind - with an interface check whatever the fault type, and `Description` defaults to `null`. - - **Overloads by constraint live in two classes**, since C# forbids two methods that differ in - constraints alone; with the type arguments inferred, the one whose constraint fails drops out. - - **The document lists problem types in the response's description**, and `fault` stays a string - on the shared problem schema; a per-operation enum is not offered. - - **An MVC action names its member in an attribute**, `[FaultStatus(nameof(…), 422)]`, - since an attribute takes only constants; a name no member has stops the host. - -50. **Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0** (2026-10-13, phase 13). Decision 34 - lets a major raise a Warning; the bar for raising one is that every report can be answered in the - code by a form that says what is meant, never by a suppression alone, and that its reach is - complete, since an Error cannot learn within the major. CMTK0003 meets both: `_ =`, a cast or a - loop variable named `_` says a drop is meant, and its reach is a return type rather than a list - of methods, complete once its 1.x gaps were closed (`Wait`, `Task.WaitAll`, a loop that never - reads its results, a lazy sequence materialised). The others stay. CMTK0005's array filled in a - loop and CMTK0006's member set in a second step (`OnInitialized`, `InitializeAsync`, a private - field `required` cannot reach) have only a suppression to say so. CMTK0008's shared identities - are answered by a cast an IDE reports as redundant, and its four known gaps would be frozen for - 2.x. CMTK0009's reach is a list of framework methods, which the `net11.0` target of a 2.x minor - may lengthen, and a sequence checked for emptiness first is correct. CMTK0007 reports test - theories. The 1.5.1 baseline's verdict, over all seven packages: under package validation's - default rules one difference, CP0021 on `FaultProblem` (decision 48); with the - attribute and parameter-name rules on as well, 29 compiler-emitted attributes (state machines - renumbered by members added before them, nullable-context bytes) and no renamed parameter. The - analyzer's severity and the query overloads' binding are behaviour no compatibility check reads; - the changelog states each. -51. **A closed set parses ignoring case only when asked, and refuses members that differ only in - case** (2026-10-13, phase 13). The ordinal parse stays the default and the only one JSON, the - column, binding and `IParsable` use, since what is stored or sent is the name as it is. A header - or a query string a client typed is read with `ignoreCase: true` at the call, as - Ardalis.SmartEnum's `FromName(name, ignoreCase)` is, and not through a per-set setting, which - would change what the wire accepts without a word at the call site. With the overload, two - members whose names differ only in case are one name to the parse, so CMTK1021 refuses them at - the declaration, where it already refused the first-letter case (`Open`, `open`) that gave - `Match` one parameter twice. The peers meet such pairs at run time: Ardalis throws on the first - ignore-case lookup, Thinktecture on the first `Get`, and Intellenum answers the first declared. - -## 9. Option and Result - -`Result`, `Result` and `Option`, with the `Option.None()`, `Result.Success()` and -`Result.Error()` markers (decision 19), ship in `CodoMetis.TypeKit`. The value-object contracts -depend on them: `IValidatedValue.Create` returns a `Result`, and the generated `TryFrom` returns an -`Option`. - -**Non-goals.** No Either, no Validation applicative, no effect or IO types, no higher-kinded type -emulation, no immutable collections. No `Try` type: it is `Result` under another name, -and an exception enters through `Result.Catch` (phase 13, decision 43). No `Unit`: `Result` -is the value-less shape. Anything that turns this into a second LanguageExt is out. - -**Shape, decided 2026-09-27.** -- **`Result`, value first.** Error-first (`Either e a`) exists in Haskell and Scala - only because a generic type there can be filled in from the left alone, which C# cannot do. - Rust, F#, Swift, DotNext, CSharpFunctionalExtensions and the csharplang unions design note all - put the value first. `IValidatedValue` does too. -- **`readonly record struct`, non-positional, private fields.** The synthesized equality is - correct (`State` included). The synthesized `ToString` prints only public members: - `Option { }` and `Result { State = Error }`. So it never prints a value, in line with - `FromKnownGood`'s message. **Never make these types positional:** positional parameters become public - properties, which reintroduces `.Value` and puts the value into `ToString`. -- **The markers** (decision 19): `Option.None()`, `Result.Success()`, `Result.Success(x)` and - `Result.Error(e)` return a small struct that converts implicitly to whatever `Option`/`Result` - the target is, so a method returns one without spelling out type arguments. They keep their - content internal too, so they print `Success { }` and `Error { }`. Only the implicit conversions - read it. Both shapes accept both result markers: `Result` converts from `Result.Error(e)` - as well as from a bare error, so a method can `return Result.Error(fault);` whichever shape it - returns. No combinator takes a marker-returning lambda: `Bind(x => Result.Success(x))` was `Map` - under another name, and is a compile error now. -- **`Result` converts from a bare value, never from a bare error** (measured - 2026-09-27). With both conversions, `Result r = 5;` compiles and is an **error**: - `int` is the more specific source type, so C# picks the error conversion. The READMEs had shown - `return Fault.X;` in valued methods, which does not compile; the consumer smoke test found it. -- **`Option.Some(null)` throws.** `notnull` is an annotation the runtime does not enforce, and a - `Some` over null reported a value it could not hand out. `Map` and the zips go through `Some`, - so a selector that returns null throws too. -- **So do `Result`'s factories.** Both shapes and both markers constrain their content to - `notnull`, and `Success(null)`/`Error(null)`/`Result.Success(null)`/`Result.Error(null)` throw - `ArgumentNullException`: a result over null handed it out of `TryGetValue`/`TryGetError` despite - `[NotNullWhen]`. `Map`, `MapError`, `Bind` and the conversions go through them. -- **Every delegate is checked before a branch is picked**, in `Option`, both `Result` shapes and - their extensions: a null for the branch not taken passed until the other outcome first arrived. - `NullDelegateTests` holds every delegate parameter of the assembly to a case run on both branches. -- **`Result.Match` hands the error to its error branch.** The only overload took a - parameterless `onError`, so `TryGetError` was the only way to the error. The parameterless one - stays, for a branch that does not need it. -- **`[DebuggerDisplay]`** on `Option` and both `Result` shapes shows the content in the debugger, - where it is what someone stepping through wants to see. The debugger is not a log. -- **`Result` has no `AsEnumerable`.** A sequence of zero or one units says no more than - the `bool` conversion. -- **Pipelines (decided 2026-09-28).** `MapAsync`/`BindAsync` take an asynchronous selector on a - result, and every combinator of both shapes continues a `Task>` (`MapAsync`, `BindAsync`, - `MapErrorAsync`, `TapAsync`), so a chain is awaited once, at its end; a completeness test holds - every combinator to a continuation. `Match` ends a chain after that `await`. Each continuation - checks its delegate before awaiting, so a null one is reported even when the pending result fails. - `Zip` combines two to six results, `Sequence` and `Traverse` a sequence of them. All stop at the - first error, in argument or sequence order: collecting errors is the validation applicative the - non-goals exclude. A zip still inspects every argument, so an uninitialized one after an error - throws. Callbacks return `Task` (decision 30): `TapAsync` and `TapErrorAsync` take a - `Func<…, Task>` beside the `Action`, so an `async` lambda is awaited; a `ValueTask` one needs - `.AsTask()`. - -**Decided 2026-09-27: an uninitialized `Result` throws.** -- A `default` result is `State == Uninitialized`. If the branching members tested only - `State == Success`, it would take the **error** branch with `default(TError)`. -- For an enum fault that is the first member (e.g. `Blank`): a plausible, wrong reason. For a - reference-type error it is `null`, despite `[NotNullWhen(false)]`. -- The analyzer blocks `default` in source, but array elements, class fields (CMTK0005/0006) and - reflection still produce such instances. -- **Decision:** every member that picks a branch (`Match`, `Map`, `Bind`, `Tap`, `TapAsync`, - `TapErrorAsync`, `TryGetValue`/`TryGetError`, `AsEnumerable`, the `bool` conversion of - `Result`) throws - `InvalidOperationException` on `Uninitialized`. That is a loud failure instead of a fabricated - fault. `State`, equality and `ToString` stay safe to call. - -**Decided 2026-09-27: `Option`/`Result` are not wire types.** -- Measured: with private state and no converter, System.Text.Json wrote `{}` for a `Some` and read - it back as `None`, and wrote `{"State":1}` for a success and read it back as `Uninitialized`. - Nothing raised. -- **Decision:** `NotWireTypeJsonConverterFactory`, on `Option`, both `Result` shapes and the - `Success`/`Success`/`Error` markers, throws `NotSupportedException` from `Read` and `Write`, - naming the type and the alternative and never the content. `HandleNull` is on, so a JSON `null` - gets the same message, and a dictionary key does too. That is what the serializer itself does for - `System.Type`. It hands every type one converter typed `object`, which the serializer wraps: a - converter per type needed `MakeGenericType`, and under Native AOT that failed for these structs - with the runtime's message instead of this one (§11). -- A serialized shape says absent with `T?`; `ToOption()`/`OrNull()` convert at the boundary; a - result is matched to a response or a document. An application that wants a wire format registers - its own converter on its options, which takes precedence over the type's attribute, in a - source-generated context too (measured). -- **Not a lossless converter** (`Some(x)` as `x`, `None` as `null`): its bytes are identical to - `T?`, so it would only move the CLR type onto the shape the design keeps it off, and a `Result` - has no sensible wire shape at all. -- **Not an analyzer:** whether a type is serialized is a property of the application's roots and - options, which the package cannot see. An application can add a shape test of its own. -- **Known gap:** the serializer calls no converter for a property that is absent from the document, - so it stays `default`. `required` or `RespectRequiredConstructorParameters` closes it, and the - README says so. -- Completeness: `NotWireTypeTests` walks every exported struct of the assembly, so a struct added - later refuses too, or is exempted on purpose. - -**Why not an existing package** (checked 2026-09-26). The criteria: no public `.Value`/`.Error`, -a generic `TError`, `default` is not success, maintained. -- **Funcky 3.6.0** is closest (`[NonDefaultable]` enforced by an analyzer, no `.Value`), but its - `Result` fixes the error type to `Exception`. -- **nlkl/Optional** has the right Option shape but no Result, and no release since 2018. -- **CSharpFunctionalExtensions, DotNext and FluentResults** expose a throwing `.Value`. -- **ErrorOr, Remora.Results and Ardalis.Result** expose an unguarded one. With ErrorOr, - CSharpFunctionalExtensions and Remora, `default(Result<…>)` reads as success. -- **JSON** (checked 2026-09-27): CSharpFunctionalExtensions 3.7.0 ships opt-in converters that write - a `Result` as a DTO with `IsSuccess`/`Error`/`Value`, for passing outcomes between services; - LanguageExt (v5 still beta) ships none; ErrorOr, OneOf and FluentResults ship none and serialize - their public properties as they are. None refuses. - -C# 15 `union` types (in .NET 11 preview) ship no standard `Option`/`Result`, and they need .NET 11 -anyway, while this repo targets net10.0. - -**Against the value-object libraries** (checked 2026-09-27): -- Vogen validation returns Ok/Invalid with a plain string, and its `TryFrom` is `bool` + `out`. -- Thinktecture returns a typed `ValidationError` but ships no Result type. -- TypeKit's value objects return `Result` from `Create` and `Option` from - `TryFrom`. That is the reason Option/Result and the value objects ship together. - -## 10. Analyzer package and rule set - -**Packaging (decided 2026-09-27).** `CodoMetis.TypeKit.Analyzers` (netstandard2.0; analyzers and -code fixes under `analyzers/dotnet/cs`). `CodoMetis.TypeKit` takes a package dependency on it. -- **Why separate:** rules and fixes can ship without a base version bump, and a consumer can take - a newer analyzer by referencing it directly. The code-fix assembly (which needs Workspaces) - stays out of a runtime library package. -- **Why still a dependency of the base package:** fabric spike consequence A. Whoever can see the - interfaces must get the guard. -- **Measured 2026-09-27 (SDK 10.0.401),** packed and consumed from a local feed: - - A project that references only `CodoMetis.TypeKit` gets CMTK0001 and CMTK0002, and so does a - project that reaches it only through another project. - - With the default `exclude="Build,Analyzers"` on the dependency, the analyzer **still** loads, - because the SDK takes analyzers from every package in the restore graph. `PrivateAssets="none"` - is kept so the dependency says what is intended instead of relying on that. - - What does drop it: a project reference with `ReferenceOutputAssembly="false"` emits **no - dependency at all**. The reference is therefore an ordinary one with `Private="false"`. -- **Guards:** `AnalyzerPackagingTests` packs both packages and reads the nuspec and the package - layout. The consumer smoke test (phase 6) asserts CMTK0001 fires, directly and transitively. - -Attributes stay in `CodoMetis.TypeKit`. The analyzer resolves them and the interfaces with -`GetTypeByMetadataName` and compares symbols (§3), so it does not reference the base package. -Inside the analyzer, rules that react to attributes are kept apart from value-object rules, so the -split stays mechanical if a generic analyzer package is ever wanted. - -**Scope.** Code that compiles but is wrong about value objects, `Result`/`Option`, types that -forbid `default`, and what a type declares by attribute because C# cannot express it (a constraint -on a type parameter, a constructor a host needs). General linting (clock types, style) stays out, since Meziantou and Roslynator -exist. - -| Id | Rule | Severity | Status | -|---|---|---|---| -| CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | -| CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | -| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 (decision 50), its 1.x gaps closed first | -| CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | -| CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | -| CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | -| CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | -| CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | -| CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | -| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within decision 34 | -| CMTK0011 | A concrete type implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a type declaring no constructor satisfies `Public`. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | -| CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | -| — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | - -**Rules that read calls see the source before weaving (measured 2026-09-28).** Metalama runs -analyzers on the unwoven source, where a call to a member the generators introduce into a value -object of the same project does not bind. CMTK0003 recognises `X.TryFrom(…)` and `x.Revalidate()`, -CMTK0007 `X.FromKnownGood(…)` (a `using static` call included) and CMTK0009 `X.TryFrom(…).OrDefault()` -by the generated member's name on a validated value object's type, which binds; the names are pinned -by the generated-surface snapshot. CMTK0001 takes a `default`'s type from the enclosing conditional or -collection when a sibling does not bind, and CMTK0006 resolves an unbound `this(…)` to the one -constructor it can mean. A local declared `var` from an unbound call, and CMTK0008 over such locals, -stay out of reach. The build-outcome consumer holds every rule to a value object of its own project, -since the verifier tests never weave. - -**Generated code (decided 2026-09-28).** The rules analyse generated code and report from it only -where a location maps (`#line`) to a `.razor` or `.cshtml` file: skipping generated code, as Roslyn -does by default, silenced every rule inside Razor components, CMTK0001 included. Anything else -generated stays unreported, which keeps EF's compiled model out of CMTK0004 (the smoke test's AOT -consumer is the measurement). `GeneratedCode.Report` is the one reporting path. - -**Transformed code (decided 2026-09-28, decision 33).** Where Metalama compiles a project, source -generators run after its transformation, so the Razor compiler's output is not in the source an -analyzer sees, and neither are the members the aspects introduce. The analyzer package's -`buildTransitive` props lists the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer`, and -Metalama's compiler runs them on the transformed code. The IDE ignores the item, so live analysis -still runs on the source, where the name-based forms above remain the reach. - -**Release discipline.** Keep `AnalyzerReleases.Shipped/Unshipped.md` tracking (RS2008). A new rule -ships at Warning or Info in a minor version and is raised to Error only in a major. Consumers -build with warnings as errors, so a new Error rule in a minor version would break their builds on -update. Within a major version an Error never reports more than it did; a Warning or Info may learn -more forms in a minor version (decision 34, stated in the analyzer README). - -## 11. Native AOT - -**Decided 2026-09-27 (decision 16), measured by publishing consumers with Native AOT** (SDK 10.0.401, -ILCompiler 10.0.12, osx-arm64), JSON through source-generated contexts, OpenAPI in a slim web -host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativeaot`. - -- **What the analyzers can and cannot see.** `IsAotCompatible` runs the trim and AOT analyzers on - the packages' own code, and warnings are errors. The generators' product is compiled in the - consumer, where only the consumer's publish sees it, and the analyzers cannot tell that a run-time - path still works: the two worst findings (stored JSON validated, every schema `{}`) produced no - warning at build time on the JIT. So the smoke test publishes with Native AOT and runs the binary. -- **Trimming removes interfaces nothing uses.** ILCompiler keeps an interface on a type only if the - interface's definition is used somewhere; `typeof(IValueObject<,>)` does not count. Neither - `[DynamicallyAccessedMembers(Interfaces)]` on the interface nor on the value object kept it (both - measured). Custom attributes are kept, which is why discovery reads - `GeneratedValueObjectAttribute<,>` (decision 17). -- **And keeps them once anything uses the interface.** The attribute's own constraint does, and so - does the EF satellite's converter: with the attribute generated, a `GetInterfaces()` lookup found - `IValueObject<,>` again under Native AOT, and without it the same host described every value object - as `{}` (both measured). That is ILCompiler's behaviour, not a contract, so the smoke test cannot - catch a return to `GetInterfaces()` at run time; the analyzers' IL2070, an error in the packages' - build, does. -- **JSON.** The generated converter's wrapped value goes through `GeneratedJson.TypeInfo` (§5), so a - context lists the value objects and never what they wrap. A value object wrapping a type the - serializer has no converter for (a class of the application's) still needs that type in the - context, and the serializer's refusal names it. -- **OpenAPI.** The host's context lists what its value objects wrap (§7). -- **EF Core** is experimental under Native AOT and needs a compiled model and precompiled queries; - its `DbContext` constructors require dynamic code. With the satellite, a value object maps, writes, - queries by `.Value`, binds as a parameter and reads a stored value without validation, in the - native binary. EF limitations met on the way, not ours to fix: - - the compiled model cannot write the sentinel of a **struct value object wrapping a reference - type** (`default(Code)` converts to `null`, and EF then needs a literal for the struct itself). - Such a value object on an entity is a `sealed partial record` instead; - - EF 10's precompiled queries cast an entity to an internal interface, which does not compile for - a **sealed** entity class. -- **Third-party packages** report their own warnings (NodaTime.Serialization.SystemTextJson 1.4.0, - EF Core). The smoke test asserts only that none names a CodoMetis package or a woven value object. - +# Design + +The design of CodoMetis.TypeKit, split by how fast each part changes. Before changing anything, read +this page, the [roadmap](roadmap.md), the [decisions' index](adr/README.md) and the note for the area +you touch; the rest is reference. + +| Page | Holds | Changes | +|---|---|---| +| [Roadmap](roadmap.md) | What is open: before the tag, 2.1, 2.x with a spike first, 3.0.0 | as work is taken or done | +| [Decisions](adr/README.md) | One record per decision, dated, with the evidence that settled it; a decision is changed by a new one that names it | append only | +| [Packages](packages.md) | The package map, what each depends on, and the namespaces | rarely | +| [Discovery is by interface](discovery.md) | Why nothing is found by name, and how the analyzer and the satellites find a value object | with the area | +| [EF Core](ef-core.md) | The mapping plugin, the convention, the translators, keys and collections | with the area | +| [Aspects](aspects.md) | The aspects, the fabric, the seams, what the generators refuse | with the area | +| [Test strategy](test-strategy.md) | What is tested where, and the guards that hold the repository to its rules | with the area | +| [OpenAPI](openapi.md) | The schema transformers and what was measured in the document | with the area | +| [Option and Result](option-result.md) | Their shape, the non-goals, the combinators and the async forms | with the area | +| [Analyzer package and rule set](analyzers.md) | Packaging, the rule table with severities, and the limits of each rule | with the area | +| [Native AOT](native-aot.md) | What is measured, how, and what each package asks of a consumer | with the area | +| [History](history.md) | The phase log from 1.0.0 to 2.0.0 as it was kept until 2026-10-13, with the measurements it recorded | closed | + +The spikes that decided something are in [spikes/](../spikes/), each with a README stating the +question, the answer and the evidence. A spike's README names the decision it led to. + +Until 2026-10-13 all of this was one file, `design/plan.md`, and links into its sections land here. diff --git a/design/roadmap.md b/design/roadmap.md new file mode 100644 index 0000000..c4d6816 --- /dev/null +++ b/design/roadmap.md @@ -0,0 +1,42 @@ +# Roadmap + +What is open, and nothing else. Done work is in the [changelog](../CHANGELOG.md) and in git, and the +phase log that recorded it until 2026-10-13 is kept as [history](history.md). Each item names the +decision that scoped it. + +## 2.0.0, before the tag + +- `2.0.0-preview.1` to nuget.org, on hold since 2026-10-13 until the user says so, then the consumer + smoke test against it ([decision 42](adr/0042-2-0-0-next-release-bundles.md)). +- The release: date the changelog's section, move the unshipped analyzer rows (CMTK0010 to CMTK0012 + new, CMTK0003 changed) to `AnalyzerReleases.Shipped.md` under the release, merge so the help links' + headings exist on `main`, and tag `v2.0.0`. The release workflow and `ChangelogTests` check each. + +## 2.1, additive + +- What the outside review of 2026-10-13 rated can-wait ([history](history.md), step 9c): the fault + name is sent even for a member without a description; `Uri.ToString()` unescapes the problem + `type`; the CMTK0011 code fix on a partial type whose other part has a primary constructor, and its + doc comment beside an attribute; CMTK0003 over `WhenAny` of held tasks; `Choose` on a sequence with + a nullable element; `Patch` under `Traverse` and `Map`. +- CMTK1021 groups names ignoring case, which misses the one `Match` collision `ToLowerInvariant` makes + and ordinal case folding does not (the Kelvin sign); it fails loudly, as LAMA0611 and CS0100. +- A `context7.json`, once the repository is public ([decision 14](adr/0014-context7-json.md)). + +## 2.x, with a spike first + +- A stable external name for a closed-set member, apart from its C# identifier: parsing, JSON, EF, + OpenAPI and the fault identifiers at once ([history](history.md), step 9b). +- The Metalama 2027.0 upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)): a minor if a consumer's own aspects beside the packed + generators still build and weave, measured as the fabric spike measured 2026.1 against the 2027.0 + preview, and a major otherwise; `[Durable]` returns to the `_value` placeholder with it. +- A `net11.0` target beside `net10.0`, in a 2.x minor when a feature needs it ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)): EF Core 11 + and Microsoft.AspNetCore.OpenApi 11 in the satellites, and a Native AOT smoke run per target. + +## 3.0.0 + +- C# 15 unions and the `net10.0` drop, timed to .NET 12 LTS (November 2027) or to the end of .NET + 10's life, whichever its consumers need ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)). The compiler-made union types have a public + `object? Value` and are serialized by System.Text.Json, so `Option` and `Result` take part in their + pattern matching only after a spike shows neither returns, on a branch of its own with no release + attached. diff --git a/design/test-strategy.md b/design/test-strategy.md new file mode 100644 index 0000000..a003ad7 --- /dev/null +++ b/design/test-strategy.md @@ -0,0 +1,31 @@ +# Test strategy + +1. **Behaviour.** Every public member of `Option`/`Result` and every generated member has a + behaviour test. Generated members are tested against woven probe types, never against code + written by hand in the test assembly. +2. **Generated surface.** A small tool reflects over the woven probe types. For each value object + it renders the public members, implemented interfaces and attributes as sorted text, which is + committed and asserted. An aspect change that adds or loses a member then shows up in review + instead of shipping silently. Once 1.0.0 ships, package validation against the last release + guards the hand-written surface too. +3. **Generated SQL.** `ToQueryString()` snapshots for `.Value`, `GetValue`, `ValueOrNull`, + `StartsWith`, equality, and `Contains` over a list of ids. +4. **OpenAPI.** A probe host's emitted document, asserted per shape against a control that uses + the wrapped type directly, so the tests follow what ASP.NET publishes for that type ([§7](openapi.md)). +5. **Performance.** Allocation tests hold the generated members to what the wrapped type allocates, + and `benchmarks/` measures time against the wrapped types. The benchmarks are not a gate: they are + run before a release and when a template changes. +6. **README and docs samples** (2026-09-28; the `docs/` pages 2026-10-13, [decision 45](adr/0045-topic-pages-docs-compiled-like-readmes.md)). Every C# + block of the READMEs and of the topic pages in `docs/` is a region in + `test/CodoMetis.TypeKit.Samples`, which references the shipping projects as a consumer does, with + the analyzer on. `ReadmeSampleTests` requires each block to equal exactly one region and each region + to be shown, so a sample that stops compiling fails the build and an edit on one side fails the test. + A docs page is discovered by being in `docs/`, and the root README's "Documentation" table has to + link it. + An output stated in a comment is asserted against the README's own comment, found by the expression + the test evaluates. A pre-release audit had found samples that did not compile (a bare fault as a + `Result`, `quantity.Value;` as a statement, an ellipsis as a lambda body), CMTK0006 on the + EF entity sample, and SQL the query does not produce; the consumer smoke test had found the first + once before ([§9](option-result.md)). Seeded: a README or sample edited alone, the bare fault back in both, the committed + READMEs, a changed SQL, JSON or string output, an opt-out on a compiled block or without a reason, an + unlabelled fence, and either parser finding nothing each fail their test. diff --git a/spikes/ClosedSets/README.md b/spikes/ClosedSets/README.md index cc15978..cd0cafb 100644 --- a/spikes/ClosedSets/README.md +++ b/spikes/ClosedSets/README.md @@ -1,7 +1,7 @@ # Spike: can a closed set be a generated type with static members, on Metalama 2026.1 and .NET 10? **Status: run 2026-10-13, answered.** From the "closed-set (smart enum) family" part of phase 13 -(2.0.0) in [design/plan.md](../../design/plan.md) §2. Decision 44 pins 2.0.0 to `net10.0` and Metalama +(2.0.0) in [the phase log](../../design/history.md). Decision 44 pins 2.0.0 to `net10.0` and Metalama 2026.1, so the family is a generated type with static members, not a C# 15 union; this spike is the measurement that nothing in it needs anything newer. diff --git a/spikes/FabricSpike/README.md b/spikes/FabricSpike/README.md index 18ad894..883477a 100644 --- a/spikes/FabricSpike/README.md +++ b/spikes/FabricSpike/README.md @@ -2,7 +2,7 @@ **Question.** Can the value-object aspects be applied by a `TransitiveProjectFabric` shipped in the aspect package, so the package holding `IValue` / `IValidatedValue<,,>` needs no Metalama at all? -The answer decides the package boundaries in [design/plan.md](../../design/plan.md). +The answer decides the package boundaries in [the design notes](../../design/plan.md). **Naming (2026-09-27).** The spike predates the package names. `Spike.Abstractions` stands for `CodoMetis.TypeKit` and `Spike.ValueObjects` for `CodoMetis.TypeKit.Generators`. The spike keeps @@ -47,7 +47,7 @@ dotnet run --project spikes/FabricSpike/Spike.App --no-build **A. The analyzer travels with the base package, not with the aspects.** Finding 8 is the fabric's price. With `[Inheritable]` on the interface, anyone who could name `IValue` also got the aspect. Now they get it only by referencing `.Generators`. So `CodoMetis.TypeKit` brings the -analyzer package along (plan §10). When a type implements a marker and the compilation does not +analyzer package along (plan [§10](../../design/analyzers.md)). When a type implements a marker and the compilation does not reference `CodoMetis.TypeKit.Generators`, it reports an error (CMTK0002). For the same reason `ForbiddenStructDefaultInitializationAnalyzer` (CMTK0001) travels the same way, since it guards the interfaces wherever they are visible. diff --git a/spikes/FaultResponses/README.md b/spikes/FaultResponses/README.md index 0d5361b..ebca941 100644 --- a/spikes/FaultResponses/README.md +++ b/spikes/FaultResponses/README.md @@ -1,7 +1,7 @@ -# Spike: can a fault response carry what a client acts on, without giving up decision 38's checks? +# Spike: can a fault response carry what a client acts on, without giving up [decision 38](../../design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s checks? **Status: run 2026-10-13, answered.** From the "Fault responses a client can act on" part of phase 13 -(2.0.0) in [design/plan.md](../../design/plan.md) §2. The fault's kind is no longer a `FaultKind` +(2.0.0) in [the phase log](../../design/history.md). The fault's kind is no longer a `FaultKind` attribute: it is a property of a closed-set member, in the same phase. **Question.** Decision 38's `ToHttpResult()` answers a fault with its status and its bare name, and @@ -47,14 +47,14 @@ status check removed, the problem types dropped from the document), and each see - **A kind's default status**: `NotFound` 404, `Conflict` 409, `Invalid` 422, `Forbidden` 403, `Unavailable` 503, through `FaultResponse.For(kind)`. A host does not change the table; a mapping of its own, which can call `FaultResponse.For` for the members it keeps, is the escape hatch, as - decision 38's switch is. `FaultKind` starts at 1, so a member whose kind is `default` is refused + [decision 38](../../design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s switch is. `FaultKind` starts at 1, so a member whose kind is `default` is refused where the set is mapped. - **Where a sentence goes.** RFC 9457 §3.1.4: `detail` helps the client correct this occurrence. A member's description is the `detail` by default, and a `Fault`'s detail replaces it; the `title` is the problem type's summary, a `FaultResponse`'s or the status's own. - **The constraint relaxed.** `FaultProblem` takes `notnull` where it took `struct, Enum`. Package validation reports it (CP0021) though no closed type it accepted is refused now; it is the - first intended difference from the 1.5.1 baseline, suppressed alone (decision 48). + first intended difference from the 1.5.1 baseline, suppressed alone ([decision 48](../../design/adr/0048-intended-break-suppressed-alone-baseline-stays.md)). ## Findings @@ -63,7 +63,7 @@ status check removed, the problem types dropped from the document), and each see | 1 | The `FaultResponse` overload checks every member at registration, as the `int` one does, and refuses a status outside 400 to 599 | measured: `A_response_per_member_is_checked_where_it_is_mapped` (200, 302, 600 refused, naming `OrderFault.NameTaken` and `responseOf`); a kind that is no `FaultKind` refused naming the member | | 2 | A `type` URI and `title` reach the body and the operation's problem response, and nothing of the input does | measured: the body carries both; the response's description lists the problem types answered with that status (`Conflict. Problem types: …`) | | 3 | `ToHttpResult()` over `Result>`, over `Result` and over a closed set's resolves without type arguments, with no ambiguity | measured: the tests call each without type arguments and compile | -| 4 | `Detail` reaches the body only when set, and the default response is unchanged from 1.5.1 | measured: the default body's members are 1.5.1's (`fault`, `status`, `title`, `traceId`, `type`), and decision 38's tests pass unchanged; `detail` appears for a `Fault` with one and a set member with a description, never for one without | +| 4 | `Detail` reaches the body only when set, and the default response is unchanged from 1.5.1 | measured: the default body's members are 1.5.1's (`fault`, `status`, `title`, `traceId`, `type`), and [decision 38](../../design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s tests pass unchanged; `detail` appears for a `Fault` with one and a set member with a description, never for one without | | 5 | `WithFaultStatus` changes the status for that endpoint only, in the response and in that operation's document entry | measured: 422 on the endpoint, 409 elsewhere; the endpoint's operation lists 422 in place of 409 | | 6 | The startup check refuses a `WithFaultStatus` naming a fault type the endpoint does not answer with | measured; and an MVC attribute naming no member | | 7 | MVC: the per-endpoint metadata reaches the operation transformer (ResultHttp finding 4 found `PopulateMetadata` handed an empty service provider) | measured: `[FaultStatus(nameof(OrderFault.NameTaken), 422)]` on an action changes its response and its operation; the transformer reads it from the endpoint's metadata, with the services from the document's context | diff --git a/spikes/MergePatch/README.md b/spikes/MergePatch/README.md index ab5cbcd..b55c9fc 100644 --- a/spikes/MergePatch/README.md +++ b/spikes/MergePatch/README.md @@ -1,7 +1,7 @@ # Spike: can a merge-patch field tell absent from null, read a value object, and survive Native AOT? **Status: run 2026-10-13, answered.** From the "`Patch`" part of phase 13 (2.0.0) in -[design/plan.md](../../design/plan.md) §2. +[the phase log](../../design/history.md). **Question.** A JSON Merge Patch (RFC 7396) field has three states, absent, null and a value, and System.Text.Json calls no converter for a property it does not find. Can one struct in the base diff --git a/spikes/RangeValueObjects/README.md b/spikes/RangeValueObjects/README.md index 7236bc3..4b94767 100644 --- a/spikes/RangeValueObjects/README.md +++ b/spikes/RangeValueObjects/README.md @@ -17,7 +17,7 @@ TypeKit's EF converter has to compose onto a mapping that another plugin provide translation has to hand the range algebra's translator an expression it recognises. **Answer: yes, with no bridge package. On 8.0.0 every gap was on the wire, in ValueRanges' JSON, and -TypeKit reproduced it exactly, as decision 26 requires. ValueRanges 9.0.0 closes all three, and the +TypeKit reproduced it exactly, as [decision 26](../../design/adr/0026-value-object-s-json-wrapped-type.md) requires. ValueRanges 9.0.0 closes all three, and the value object inherits each fix with no change in TypeKit.** Measured 2026-10-11 against value objects woven by the real generators (main at 1.5.0), CodoMetis.ValueRanges and CodoMetis.ValueRanges.EFCore.PostgreSQL 8.0.0, then 9.0.0, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3, @@ -67,13 +67,13 @@ OpenAPI transformer (`ValueRangesSchemaTransformer.cs`, copied verbatim) added b | 7 | JSON **without** `AddRangeConverters()`. On 8.0.0: `DateRange` writes `{"Length":181}`, and reading throws `NotSupportedException` (an abstract type). The value object writes the same `{"Length":181}`, reports "could not read the JSON value as DateRange", and an ASP.NET body holding it is a 400. Parity held, but the wire was wrong for both types, and it failed silently on writing. On 9.0.0: both write `"[2026-01-01,2026-06-30]"` and read it back, under default and web options. A refused range is "refused the JSON value (NotFinite)". With nothing registered, a valid body now binds (200), and a refused one is a 400 | ✅ 9.0.0 | | 8 | A dictionary key. On 8.0.0: neither `DateRange` nor the value object can be one, with or without the converters. On 9.0.0: both write `{"[2026-01-01,2026-06-30]":1}` under all four option sets. A `Dictionary` body binds (200), and a key `Create` refuses is a 400 | ✅ 9.0.0 | | 9 | EF model: in both registration orders (`UseNpgsql(n => n.UseValueRanges()).UseTypeKit()` and `UseTypeKit().UseNpgsql(…)`), `Period` and the nullable `Availability` map to `daterange`. Their mapping is ValueRanges' `ValueRangeTypeMapping` with TypeKit's converter composed onto it (`CompositeValueConverter`). `HasIndex(c => c.Period).HasMethod("gist")` creates a GiST index | ✅ | -| 10 | Round trip on PostgreSQL: the values read back equal to those written. A row the rule refuses, inserted by SQL (`'[2026-01-01,)'`), materializes without validation, as §4 intends, and `Revalidate()` reports `NotFinite` | ✅ | +| 10 | Round trip on PostgreSQL: the values read back equal to those written. A row the rule refuses, inserted by SQL (`'[2026-01-01,)'`), materializes without validation, as [§4](../../design/ef-core.md) intends, and `Revalidate()` reports `NotFinite` | ✅ | | 11 | Translation composes. See the table below; every query ran and returned the in-memory answer | ✅ | | 12 | ASP.NET Core minimal APIs, in a host with `AddRangeConverters()`: a valid body binds and echoes, and a body `Create` refuses is a 400. A query parameter binds through `TryParse` (`?period=[2026-01-01,2026-06-30]` → 200), and a refused one is a 400. No ASP0020 for a query parameter of a value object declared in the same project | ✅ | -| 13 | OpenAPI with `AddTypeKit()`: the value object's schema is `DateRange`'s, as §7 requires. On 8.0.0 that is `{}` with the converters and an object with `length` without them. On 9.0.0 it is `{}` in every host, since ASP.NET cannot see through a converter. With ValueRanges' documented transformer, in either order with `AddTypeKit()`, both component schemas are `type: string` with the literal's `pattern` and description. TypeKit asks ASP.NET for the wrapped schema (`GetOrCreateSchemaAsync`), so the other transformer reaches the value object with no wiring | ✅ 9.0.0 + the copied transformer | +| 13 | OpenAPI with `AddTypeKit()`: the value object's schema is `DateRange`'s, as [§7](../../design/openapi.md) requires. On 8.0.0 that is `{}` with the converters and an object with `length` without them. On 9.0.0 it is `{}` in every host, since ASP.NET cannot see through a converter. With ValueRanges' documented transformer, in either order with `AddTypeKit()`, both component schemas are `type: string` with the literal's `pattern` and description. TypeKit asks ASP.NET for the wrapped schema (`GetOrCreateSchemaAsync`), so the other transformer reaches the value object with no wiring | ✅ 9.0.0 + the copied transformer | | 14 | A query parameter. ASP.NET describes a `DateRange` query parameter as `{"type":"string"}`: a `TryParse`-bound parameter is its own schema only for a primitive, an enum and eight listed types (`EndpointModelMetadata.GetDisplayType`, internal), and `string` for anything else. TypeKit replaced its `string` with the wrapped type's JSON schema regardless, which gave `{}` for the value object (and, with the transformer, a pattern a `DateRange` parameter does not get). Fixed in `ValueObjectSchemaTransformer`: the placeholder stays where ASP.NET keeps it for the wrapped type. After the fix the value object's query parameter is `{"type":"string"}` in all four hosts, as `DateRange`'s is | ✅ fixed in TypeKit | | 15 | Configuration binding. 9.0.0 gives `DateRange` a `TypeConverter` (`RangeTypeConverter`). `"[2026-01-01,2026-06-30]"` binds to both a `ContractPeriod?` and a `DateRange?` property. A range `Create` refuses and text that is no range both throw `InvalidOperationException` ("Failed to convert configuration value … to type 'ContractPeriod'"): the binder's message quotes the value, the value object's own does not | ✅ | -| 16 | A query array (`[FromQuery] ContractPeriod[]`): ASP.NET gives a `DateRange[]` query parameter no `items`, as it gives any converter-backed element none (§7). TypeKit's container branch puts the value object's component back, as for every value object (`{}` on 9.0.0, the literal's pattern with the copied transformer). Two values bind (200) in every host | ✅ | +| 16 | A query array (`[FromQuery] ContractPeriod[]`): ASP.NET gives a `DateRange[]` query parameter no `items`, as it gives any converter-backed element none ([§7](../../design/openapi.md)). TypeKit's container branch puts the value object's component back, as for every value object (`{}` on 9.0.0, the literal's pattern with the copied transformer). Two values bind (200) in every host | ✅ | Queries (finding 11), as `ToQueryString()` prints them: diff --git a/spikes/ResultCatch/README.md b/spikes/ResultCatch/README.md index dce981e..42de4d0 100644 --- a/spikes/ResultCatch/README.md +++ b/spikes/ResultCatch/README.md @@ -1,7 +1,7 @@ # Spike: can an exception enter a `Result` through one factory, without a `Try` type? **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 -(2.0.0) in [design/plan.md](../../design/plan.md) §2, decision 43. §9 keeps `Try` and `Unit` out: +(2.0.0) in [the phase log](../../design/history.md), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md). [§9](../../design/option-result.md) keeps `Try` and `Unit` out: a `Try` is `Result` under a second name, and `Result` is the value-less shape a `Unit` would stand in for. diff --git a/spikes/ResultHttp/README.md b/spikes/ResultHttp/README.md index c17962e..e82e4de 100644 --- a/spikes/ResultHttp/README.md +++ b/spikes/ResultHttp/README.md @@ -2,7 +2,7 @@ **Question.** Phase 12 puts "a `Result` as an HTTP response" into `CodoMetis.TypeKit.AspNetCore`: a success as its value, a fault as RFC 9457 problem details with a status the host maps, and the -endpoint's responses in the OpenAPI document. The domain must not reference ASP.NET, and §9's refusal +endpoint's responses in the OpenAPI document. The domain must not reference ASP.NET, and [§9](../../design/option-result.md)'s refusal to serialize a `Result` must stand. Which shape does ASP.NET Core 10 support for that, in minimal APIs and MVC, under Native AOT, and what happens when a host forgets to map a fault type? @@ -67,14 +67,14 @@ arguments from the result through a visitor, as `GeneratedValueObjectAttribute.A | 6 | Candidate 2 (metadata records only the fault type; the transformer reads the mapping): MVC and minimal APIs both documented (findings 2, 3). An operation transformer runs after ASP.NET hoisted schemas into components, so a schema from `GetOrCreateSchemaAsync` stays inline; `Document.AddComponent` plus an `OpenApiSchemaReference` gives the `$ref` ASP.NET's own metadata gives | ✅ | | 7 | Candidate 2 without the startup check, unmapped: only a fault response fails (`500`, the exception naming the fault type and `AddFaultResponses`), and the document fails; successes are served. Loud only once a fault occurs | ✅ | | 8 | A hosted service cannot do the startup check: hosted services start before the pipeline is built, and `EndpointDataSource` then lists 0 endpoints. An `IStartupFilter` sees all of them after `next(app)` (7 here, MVC included) and its exception fails `StartAsync` before the server listens, naming each endpoint and fault type | ✅ | -| 9 | Shape B under JIT: the filter turns the result into the same response, but the document describes the `200` as `ResultOfOrderAndOrderFault`, whose schema is the type's XML doc description, and lists no problem response; the startup check cannot see it, since nothing records its fault type. Without the filter, §9's refusal answers `500` with its message, and the document still promises a `Result` | ✅ | -| 10 | Shape B under Native AOT: the host does not start. The Request Delegate Generator asks for `JsonTypeInfo` of `Result` when the endpoint is built. With the result added to the host's context it starts and the filter answers correctly; the unfiltered endpoint is §9's refusal again | ✅ | +| 9 | Shape B under JIT: the filter turns the result into the same response, but the document describes the `200` as `ResultOfOrderAndOrderFault`, whose schema is the type's XML doc description, and lists no problem response; the startup check cannot see it, since nothing records its fault type. Without the filter, [§9](../../design/option-result.md)'s refusal answers `500` with its message, and the document still promises a `Result` | ✅ | +| 10 | Shape B under Native AOT: the host does not start. The Request Delegate Generator asks for `JsonTypeInfo` of `Result` when the endpoint is built. With the result added to the host's context it starts and the filter answers correctly; the unfiltered endpoint is [§9](../../design/option-result.md)'s refusal again | ✅ | | 11 | Shape A under Native AOT: no trim or AOT warning in the publish. Every fault answers `500` (`JsonTypeInfo` for `ProblemDetails` missing, the document too) until `AddProblemDetails()` is called, which puts ASP.NET's problem-details context into the host's resolver chain. The candidate calls it in `AddFaultResponses` | ✅ | | 12 | With it, Native AOT answers and documents exactly as JIT does (findings 1, 2), and the startup check refuses an unmapped fault type. `AddProblemDetails()` also adds a `traceId` to every problem, as ASP.NET does for its own | ✅ | ## Consequences for the design -- **Shape A, explicit.** `ToHttpResult()` at the endpoint, as decision 19 prefers concise to implicit: +- **Shape A, explicit.** `ToHttpResult()` at the endpoint, as [decision 19](../../design/adr/0019-concise-implicit.md) prefers concise to implicit: the handler's return type is ASP.NET's own `Results<…>`, so the success's schema, MVC and the Request Delegate Generator all work without anything of ours (findings 1–3, 11). Shape B is not offered: it documents a `Result` (finding 9) and makes a Native AOT host put one in its context diff --git a/spikes/SensitiveValues/README.md b/spikes/SensitiveValues/README.md index 97fae54..d67ba69 100644 --- a/spikes/SensitiveValues/README.md +++ b/spikes/SensitiveValues/README.md @@ -34,7 +34,7 @@ prototype Serilog destructuring policy. | 4 | Serilog: `{Masked}` prints the mask, but `{@Masked}` destructures the public properties, `MaskedEmail { Value: "ann@example.com" }`, and so does `{@Signup}` for every value object inside. Every value object, masked or not, is logged as `{ Value: … }` there | ✅ | | 5 | A destructuring policy that turns any value object (recognised by `GeneratedValueObjectAttribute<,>`) into a scalar of its `ToString()`: `{@Masked}` and `{@Signup}` print the mask, and an unmasked value object prints its value as a plain string instead of `{ Value: … }` | ✅ | | 6 | A `DataClassificationAttribute` on the value object's *type*: the logging generator reports LOGGEN035 ("has a sensitive field/property in its type") and generates nothing for the method, which then fails with CS8795 | ✅ | -| 7 | The debugger: no value object declares `[DebuggerDisplay]` or hides `Value`, so the debugger shows `ToString()`, the mask, and expands `Value`. §9 already holds that the debugger is not a log | ✅ | +| 7 | The debugger: no value object declares `[DebuggerDisplay]` or hides `Value`, so the debugger shows `ToString()`, the mask, and expands `Value`. [§9](../../design/option-result.md) already holds that the debugger is not a log | ✅ | ## Consequences diff --git a/spikes/TypeArgumentConstraints/README.md b/spikes/TypeArgumentConstraints/README.md index 4d63de3..0232917 100644 --- a/spikes/TypeArgumentConstraints/README.md +++ b/spikes/TypeArgumentConstraints/README.md @@ -1,7 +1,7 @@ # Spike: can an attribute on a type parameter constrain its arguments where C# cannot? **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "Constraints a type parameter -cannot declare" part of phase 13 (2.0.0) in [design/plan.md](../../design/plan.md) §2, decision 43, and +cannot declare" part of phase 13 (2.0.0) in [the phase log](../../design/history.md), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md), and the CMTK0010 row of §10. **Question.** C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : @@ -12,7 +12,7 @@ analyzer rule, CMTK0010, make the violation an error at every closed use, in eve without Metalama? An analyzer rather than an aspect, because an aspect on the parameter sees the declaration and -never the use, and the use site need not compile with Metalama (§3: the base package is +never the use, and the use site need not compile with Metalama ([§3](../../design/discovery.md): the base package is Metalama-free, and discovery is by the attribute symbol). **Answer: yes.** Measured 2026-10-13 on .NET SDK 10.0.401 with the rule as `.Analyzers` ships it @@ -54,6 +54,6 @@ Each row is a claim the spike must settle. A seed that breaks the guard proves e | 8 | `[Record]` accepts a record class and a record struct; `[Abstract]` accepts an abstract class or record and refuses an interface; `[Sealed]` accepts a sealed class, a struct and a record struct; `[Concrete]` refuses an abstract type and an interface | measured: a sealed record, a struct and an enum pass `[Sealed]`, an interface fails it and `[Abstract]`; an abstract class fails `[Record]` and `[Concrete]`; a struct passes `[Concrete]` | | 9 | The four attributes compose: `[Abstract, Record]` requires both | measured: `[Abstract, Record]` reports each attribute broken, so an abstract class reports `is not a record` alone | | 10 | The open generic itself (`Base` inside its own body) is never reported | measured: `Decider` inside its own body and `typeof(Decider<,>)` report nothing; the library builds clean | -| 11 | A closed use in a Razor component is reported (§10, generated code with a mapped location) | measured: `Probe.razor(4,9): error CMTK0010` in a Razor class library | +| 11 | A closed use in a Razor component is reported ([§10](../../design/analyzers.md), generated code with a mapped location) | measured: `Probe.razor(4,9): error CMTK0010` in a Razor class library | | 12 | `MakeGenericType` at run time is out of reach, stated in the README, and the attribute carries no run-time check | by design: no run-time check, the README says `MakeGenericType` is out of reach | | 13 | The attributes have `AttributeTargets.GenericParameter` only, so a misplaced one is CS0592 | measured in `TypeArgumentConstraintAnalyzerTests`: `[Abstract]` on a class is CS0592 | diff --git a/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs index adadccb..bb45c50 100644 --- a/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/KnownGoodFromCallerAnalyzer.cs @@ -26,7 +26,7 @@ namespace CodoMetis.TypeKit.Analyzers; /// The call is recognised by syntax, since on a value object of the same project it does not bind /// where Metalama runs analyzers (). A value the code /// produced itself is not reported, since "a value the caller just produced" is -/// legitimate (design/plan.md §10): a constant, a local, a field, a method's result, an interpolation. +/// legitimate (design/analyzers.md): a constant, a local, a field, a method's result, an interpolation. /// A parameter copied into a local first is therefore not seen. Info, because a private helper that /// only ever receives constants, and a test theory's parameters, are reported too. /// diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index ec1f1f3..e17de4d 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -492,7 +492,7 @@ the refusal of writing an absent `Patch`, which no document can carry: mark t `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]`. **Why:** without it a `Some` is written as `{}` and read back as `None`, and nothing is raised; see -[Not wire types](#not-wire-types) and the [design](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/design/plan.md#9-option-and-result). +[Not wire types](#not-wire-types) and the [design](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/design/option-result.md). **Instead:** say absent with `T?` in the shape, and convert at the boundary: `dto.Nickname.ToOption()` on the way in, `nickname.OrNull()` on the way out. `Match` a result to a response. A source-generated @@ -559,7 +559,7 @@ converter, or `JsonException: Quantity could not read the JSON value as Int32.` **Why:** the wrapped type's own message quotes the input ("The input string '…' was not in a correct format."), so it is replaced, and not kept as the inner exception either; see the -[design](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/design/plan.md#8-decisions), +[design](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/design/adr/README.md), decision 27. A JSON error still carries its path. **Instead:** `TryParse` for text that may not parse. Text that is yours to log, log before parsing. diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs index 145754b..1c6e642 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/AotCompatibilityTests.cs @@ -3,7 +3,7 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// /// Every package that ships run-time code is built with the trim and AOT analyzers on /// (IsAotCompatible), so a call that trimming or Native AOT cannot honour fails the build, -/// where warnings are errors, instead of a consumer's publish (design/plan.md §11). +/// where warnings are errors, instead of a consumer's publish (design/native-aot.md). /// /// /// Read from the packed assembly, which carries the SDK's IsAotCompatible metadata when the diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/DesignRecordTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/DesignRecordTests.cs new file mode 100644 index 0000000..cb54783 --- /dev/null +++ b/test/CodoMetis.TypeKit.Conventions.Tests/DesignRecordTests.cs @@ -0,0 +1,44 @@ +namespace CodoMetis.TypeKit.Conventions.Tests; + +/// +/// The decision records under design/adr/: one file per decision, numbered without a gap, each +/// with its title, its date and its status, and every one listed in the index. A record nobody can +/// find, or one without the date and status a reader scans for, is a decision that will be taken +/// again. +/// +public sealed class DesignRecordTests +{ + private static readonly string Records = Path.Combine(Repository.Root, "design", "adr"); + + private static IReadOnlyList Names => + [.. Directory.EnumerateFiles(Records, "*.md").Select(Path.GetFileName).Where(name => name != "README.md").Order(StringComparer.Ordinal)!]; + + public static TheoryData RecordNames => [.. Names]; + + [Fact] + public void The_records_are_found() => Names.Count.ShouldBeGreaterThanOrEqualTo(50, string.Join(", ", Names)); + + [Fact] + public void The_records_are_numbered_without_a_gap() => + Names.Select(name => int.Parse(name[..4])).ShouldBe(Enumerable.Range(1, Names.Count), "Each record is NNNN-slug.md, and the numbers run from 1 without a gap."); + + [Theory] + [MemberData(nameof(RecordNames))] + public void A_record_has_its_title_date_and_status(string name) + { + var text = File.ReadAllText(Path.Combine(Records, name)); + var lines = text.Split('\n'); + + lines[0].ShouldStartWith($"# {name[..4]}. ", customMessage: $"{name} opens with its number and title."); + text.ShouldMatch(@"(?m)^Date: \d{4}-\d{2}-\d{2}\s*$", $"{name} has a 'Date: YYYY-MM-DD' line."); + text.ShouldMatch(@"(?m)^Status: (accepted|open|superseded by \d{4})\s*$", $"{name} has a 'Status:' line of accepted, open or superseded by NNNN."); + } + + [Fact] + public void The_index_lists_every_record() + { + var index = File.ReadAllText(Path.Combine(Records, "README.md")); + + Names.Where(name => !index.Contains($"]({name})", StringComparison.Ordinal)).ShouldBeEmpty("Every record is linked from design/adr/README.md."); + } +} diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs index 31e860d..2ec8461 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs @@ -4,15 +4,21 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// -/// Every relative link in the root README, the package READMEs and the docs/ pages opens a -/// file or directory that exists, and every anchor in one is a heading of the file it points at, derived as +/// Every relative link in the root README, the package READMEs, the docs/ pages and the +/// design/ pages opens a file or directory that exists, and every anchor in one is a heading of the file it points at, derived as /// GitHub derives it. A link to a renamed page is a 404; a link to a renamed heading opens the top /// of the page, silently, which is the worse of the two. /// public sealed partial class DocumentationLinkTests { private static readonly Lazy> Links = new(() => - [.. ReadmeSamples.Readmes().SelectMany(page => RelativeLinks(page, File.ReadAllText(Path.Combine(Repository.Root, page))))]); + [.. ReadmeSamples.Readmes().Concat(DesignPages()).SelectMany(page => RelativeLinks(page, File.ReadAllText(Path.Combine(Repository.Root, page))))]); + + /// The design notes, the decision records and the roadmap under design/: linked like the pages, compiled by nothing. + private static IEnumerable DesignPages() => + Directory.EnumerateFiles(Path.Combine(Repository.Root, "design"), "*.md", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(Repository.Root, path).Replace('\\', '/')) + .Order(StringComparer.Ordinal); public static TheoryData Locations => [.. Links.Value.Select(link => link.Location)]; diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs index f57ad39..82b5727 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs @@ -11,7 +11,7 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// A pre-release audit compiled the READMEs' blocks and found a bare fault returned from a method typed /// Result<T, TError>, statements that are not statements (quantity.Value;), and SQL in a /// comment that the query does not produce. The consumer smoke test had found the first once before -/// (design/plan.md §9). Each was fixed by hand, and nothing kept the next one out. +/// (design/option-result.md). Each was fixed by hand, and nothing kept the next one out. /// /// /// A block that cannot compile on purpose, showing a compile error, says so above its fence: From bf67c3b93b761857a9afbf973efefd04f65b6dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:37:09 +0200 Subject: [PATCH 044/147] docs: no history page, since git and the changelog hold the phases; decision 14 revised, the repository is public The phase log kept as design/history.md duplicated what git holds (`git show 4237abb:design/plan.md`) and what the changelog summarises; it is gone, and the map, the roadmap and the spike READMEs say where the log is. Decision 14 said to add a context7.json once the repository is public; it has been since v1.0.0 (2026-09-28), and Context7 already indexes the repository as /caffeinatedcoder/codometis.typekit. The record is revised to say so and is accepted, the roadmap item is done, and the file follows the sibling repositories' shape, the Context7 URL and the account's public key. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +- context7.json | 4 + design/adr/0014-context7-json.md | 14 +- design/adr/README.md | 2 +- design/history.md | 920 ----------------------- design/plan.md | 4 +- design/roadmap.md | 10 +- spikes/ClosedSets/README.md | 2 +- spikes/FaultResponses/README.md | 2 +- spikes/MergePatch/README.md | 2 +- spikes/ResultCatch/README.md | 2 +- spikes/TypeArgumentConstraints/README.md | 2 +- 12 files changed, 29 insertions(+), 939 deletions(-) create mode 100644 context7.json delete mode 100644 design/history.md diff --git a/CHANGELOG.md b/CHANGELOG.md index beac65f..5e30453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,8 +149,8 @@ this release adds, so nothing that built against 1.5.1 meets them. outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete public surface, checked against the types. The package READMEs remain the reference for each package. The plan moved from `docs/plan.md` to `design/plan.md`, and on 2026-10-13 was split: a decision - record per decision under `design/adr/`, a design note per area, a roadmap, the phase log as - history, and `design/plan.md` as the map. + record per decision under `design/adr/`, a design note per area, a roadmap, and `design/plan.md` + as the map; the phase log stays in git. - **Fault responses a client can act on.** In `CodoMetis.TypeKit`: `FaultKind`, `IFault` and `IFaultSet`, a closed set whose members carry a kind and an optional description, and `Fault`, a fault with a sentence about one occurrence (`Fault.Of(reason, detail)`). In diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..cf367da --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/caffeinatedcoder/codometis.typekit", + "public_key": "pk_DZ1KMuxb1Q651t4CnyA6E" +} diff --git a/design/adr/0014-context7-json.md b/design/adr/0014-context7-json.md index 3562b94..54a4a61 100644 --- a/design/adr/0014-context7-json.md +++ b/design/adr/0014-context7-json.md @@ -1,6 +1,14 @@ -# 0014. context7.json. +# 0014. A `context7.json` links the repository to its Context7 entry Date: 2026-10-13 -Status: open +Status: accepted -The sibling repos register one. Add it once the repo is public. +Context: taken undated in the plan, before the first release, as "add it once the repo is public"; +revised 2026-10-13. + +The repository has been public since v1.0.0 (2026-09-28), and Context7 indexes it as +`/caffeinatedcoder/codometis.typekit`, 459 code snippets from the READMEs and the topic pages on +2026-10-13. The sibling repositories, CodoMetis.ValueRanges and EFCore.ComplexIndexes, carry a +`context7.json` at their root naming their Context7 URL and the account's public key, which links the +repository to the entry so that its documentation is re-indexed from source. This one carries the +same, with its own URL. The key is a public one, already in both siblings. diff --git a/design/adr/README.md b/design/adr/README.md index 4798331..5c0100d 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -20,7 +20,7 @@ per area ([design](../plan.md)) link to the records they rest on. | [0011](0011-openapi-schema-transformer.md) | OpenAPI through one schema transformer | 2026-09-27 | accepted | | [0012](0012-asp0020-documented-suppressed.md) | ASP0020 is documented, not suppressed | 2026-09-27 | accepted | | [0013](0013-first-release-s-rule-set-cmtk0001.md) | The first release's rule set is CMTK0001–CMTK0009 | 2026-09-28 | accepted | -| [0014](0014-context7-json.md) | context7.json. | 2026-10-13 | open | +| [0014](0014-context7-json.md) | A `context7.json` links the repository to its Context7 entry | 2026-10-13 | accepted | | [0015](0015-option-result-wire-types.md) | `Option`/`Result` are not wire types | 2026-09-27 | accepted | | [0016](0016-native-aot-wherever-platform-allows.md) | Native AOT wherever the platform allows it | 2026-09-27 | accepted | | [0017](0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md) | Run-time discovery reads `GeneratedValueObjectAttribute` | 2026-09-27 | accepted | diff --git a/design/history.md b/design/history.md deleted file mode 100644 index ca94872..0000000 --- a/design/history.md +++ /dev/null @@ -1,920 +0,0 @@ -# History: the phases - -The phase log as it was kept in `design/plan.md` until 2026-10-13: phases 0 to 13, each step with its -date and the measurements it recorded. It is closed. What is open is in the [roadmap](roadmap.md), what -was decided is in [the decisions](adr/README.md), and nothing here is read before a change: it is where -a "why" that the changelog does not hold is found. - -Status at the split: **1.0.0 released 2026-09-28** (nuget.org, GitHub release `v1.0.0`). Phases 0 to 11 are done; -what is left for 1.x is listed at the end of phase 11, and package validation holds every pack to the -1.5.0 surface. Phase 12 is 1.5.0, released 2026-10-11; 1.5.1 followed the same day with a fix to parameter schemas. Phase 13 is the next release, 2.0.0 (decisions [42](adr/0042-2-0-0-next-release-bundles.md) and [44](adr/0044-2-0-0-net10-0-metalama.md): `net10.0`, Metalama 2026.1). The decisions are in [§8](adr/README.md), Native AOT in §11. The fabric spike -([spikes/FabricSpike](../spikes/FabricSpike/README.md)), the translation comparison -([spikes/ValueTranslation](../spikes/ValueTranslation/README.md)), the EF mapping spike -([spikes/EfMapping](../spikes/EfMapping/README.md)) and the OpenAPI spike -([spikes/OpenApiSchemas](../spikes/OpenApiSchemas/README.md)) are done, and so is phase 12's -`Result`-to-HTTP spike ([spikes/ResultHttp](../spikes/ResultHttp/README.md)). - -## The phases - -Each phase ends green, and its guards have been proven by seeding the defect -(`.claude/skills/verify-the-guard`). - -0. **Repo skeleton. ✅ Done 2026-09-27.** - - `Directory.Build.props`: warnings as errors, CS1591, Source Link, and package validation - without a baseline until the first release. - - `Directory.Packages.props` (central management; `spikes/` opts out), and `global.json` - pinned to 10.0.401 / `latestPatch`. - - The five shipping projects, still empty, in `CodoMetis.TypeKit.slnx`. - - The analyzer targets netstandard2.0 on Roslyn 5.0.0 (the floor), with RS2008 release tracking. - - `.github/workflows/dotnet.yml` (build and test, SHA-pinned actions, SDK from global.json) and - `dependabot.yml`. - - First guard: `MetalamaBoundaryTests`. Only `.Generators` may resolve Metalama, directly or - transitively. Proven by seeding a direct reference in the base package, which failed the base - and both satellites, and a transitive one via the EF satellite, which failed only that - satellite. -1. **Option and Result. ✅ Done 2026-09-27.** - - `Option`, `Result`, `Result`, the `Result.Success`/`Result.Error` markers, - their extensions and `RequireCustomInitializationAttribute` in `CodoMetis.TypeKit`. Tests in - `test/CodoMetis.TypeKit.Tests`. - - The throw on `Uninitialized`, a `ToString` that never prints the content, and a - `[DebuggerDisplay]` that does ([§9](option-result.md)). - - Guards, each proven by seeding its defect: - - one throw case per branching member (21), plus a completeness test that fails when a - public `Result` member is added without being classified as branching or not; - - `ToString` never contains the content, including for the markers; - - the debugger display names an existing private property and shows the content; - - the collapsing `Match` runs its success callback. - - Found in the fourth review on 2026-09-27, and fixed: `Option`/`Result` serialized as `{}` and - `{"State":1}` and read back as `None` and uninitialized, silently. Every exported struct now - carries `NotWireTypeJsonConverterFactory` ([§9](option-result.md)). Guard: with the attributes removed, 58 of the 62 - `NotWireTypeTests` fail, the other 4 pin the escape hatch and the absent-property gap. - - Found in the fifth review on 2026-09-27, and fixed: `Result` never checked its delegates, so a - null for the branch not taken passed until the other outcome first arrived (`Match(null, …)` on - every error), and `Option.ToResult` and the zips lost `Option`'s own check inside a lambda. Every - delegate is now checked before a branch is picked ([§9](option-result.md)). Guard: `NullDelegateTests` runs each case - on both branches, and its completeness test holds every delegate parameter of the assembly to a - case; with the checks reverted, 29 of its 42 tests fail. -2. **Value-object contracts and analyzer. ✅ Done 2026-09-27.** - - The contracts: `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<>`, `IValidatedValue<,,>`, - `IValueObjectMaterializer<,>`, `GeneratedFactories`, `TranslatedAsWrappedValueAttribute`, and - `GuidValueExtensions` with `New()` and `New(DateTimeOffset)`. No NodaTime. - - CMTK0001 and CMTK0002, resolved by symbol once per compilation ([§3](discovery.md)). CMTK0001 also follows a - type parameter's constraints (`default(T)`/`new T()` where `T : struct, IValue<…>`, or - `T : IValue<…>, new()`, which only a struct value object satisfies). - - The verifier tests compile against the real `CodoMetis.TypeKit` assembly, with no copy of the - contracts, and `default(Option)` is one of the positive controls. - - Packaging ([§10](analyzers.md)): `CodoMetis.TypeKit` depends on `CodoMetis.TypeKit.Analyzers`, which ships only - `analyzers/dotnet/cs`. `AnalyzerPackagingTests` packs both and reads the result. - - Guards, each proven by seeding its defect: - - a drifted metadata name silences the positive controls (10 tests for `IValue`1`, 9 for the - attribute); - - losing indirect markers (2) or type-parameter constraints (2) fails their tests; - - matching the generators by prefix or by suffix fails the look-alike cases (1 each); - - dropping the dependency, excluding its analyzers, packing the analyzer into `lib/` or - marking it a development dependency each fail a packaging test. -3. **Generators. ✅ Done 2026-09-27.** - - The ten aspects, the transitive fabric and `AspectOrder` in `CodoMetis.TypeKit.Generators`, - built on Metalama 2026.1.28 ([§5](aspects.md)). - - Every generated way into a validated value object applies `Create` ([§5](aspects.md), entry points). - - Probes in `test/CodoMetis.TypeKit.Generators.Probes`, one per JSON and parsing strategy plus - the edge cases, and behaviour tests for every generated member against them. The tests reach - the generators only through the probes, which is the transitive-fabric check. - - The generated-surface snapshot ([§6](test-strategy.md)), `GeneratedSurface.verified.txt`. - - Build-outcome tests: a throwaway consumer with declarations that cannot be generated gets - exactly CMTK1000–1009 and CMTK0001, nothing else, and no CMTK0002 beside the real generators. - - Guards, each proven by seeding its defect: - - a JSON read, `Parse` or `TryParse` that bypasses `Create` fails 7, 9 and 5 entry-point - cases; a new public factory without a case fails the completeness tests; - - `MinValue` on a validated number, a missing JSON-null or `Parse(null)` guard, and a fabric - that misses indirect markers or nested types each fail their tests, and the surface ones - the snapshot too; - - without the more-than-one-marker or the generic-type check, the build-outcome tests see an - aspect failure instead of CMTK1003/1005; - - an analyzer that stops seeing value objects fails the CMTK0001 build test, and one that - looks for a misspelled generators assembly breaks the probes build with CMTK0002. - - Found while building them, and fixed: JSON reads and parsing constructed validated value objects - without `Create`; `MinValue`/`MaxValue` wrapped an unvalidated bound; a JSON `null` or - `Parse(null)` wrapped null; `bool`/`char` (explicit `IParsable`) did not compile; a NodaTime - `LocalDate` could not be a dictionary key; an internal value object got a public extension - class; a marker reached through a derived interface would not have been generated. - - Found in review on 2026-09-27, and fixed with guards proven by reverting: an enum-backed value - object's parsing threw for every input; string ordering was culture-sensitive while equality - is ordinal; `Parse(x.ToString(), null)` did not round-trip outside the invariant culture; - `IValue` failed as an aspect bug instead of CMTK1005; a fallback JSON key did not read - back; a record class's comparison threw on null ([§5](aspects.md)); `Option.Some(null)` reported a value; - and `Result` did not accept the `Result.Error(e)` marker ([§9](option-result.md)). - - Found in the second review on 2026-09-27, and fixed the same way (39 guards fail with every fix - reverted): two nested value objects of one name crashed Metalama, and a declared - `{Name}Extensions` failed the aspect (CMTK1007 now); malformed JSON threw `FormatException`, - which a minimal API answers with 500; interpolation formatted in the current culture, so - `Parse($"{x}", null)` read "1,5" as 15; a `DateTime` without an offset was converted through - the server's time zone; `From(null)` wrapped null; `Result` held a null value or error; - `Result.Match` could not see the error ([§9](option-result.md)); a derived record-class value object failed - inside the generated code (CMTK1006 now); CMTK0001 missed `new T()` under `new()`. - - Found in the third review on 2026-09-27: a hand-written comparison operator failed the aspect - (LAMA0500) and a hand-written object `CompareTo` was kept silently beside the generated generic - one; both are CMTK1008 now, and the one seam, `CompareTo(TSelf)`, is documented and pinned ([§5](aspects.md)). - - Found by the OpenAPI spike on 2026-09-27 (finding 16): a record class that wraps itself, or - two that wrap each other, compiled without a word, and a struct cycle failed as LAMA0611. Every - value object over a value object is CMTK1005 now ([§5](aspects.md)). Guards, each proven by seeding its - defect: without the refusal 12 build-outcome tests fail (the struct cycle's LAMA0611 among - them); reporting a cycle as plain nesting fails 4 (a self-wrap then compiles again); allowing - nesting without a cycle fails 2. - - Found in the fifth review on 2026-09-27: a hand-written constructor. One with the generated - constructor's signature failed as LAMA0611, a positional record (`OrderId(Guid Value)`) as - LAMA0500 or an exception in the aspect (LAMA0041), and any other compiled without a word: it - skipped `Create`, left `Value` null, or, as a record's copy constructor, did that to `with`. - Every hand-written instance constructor is CMTK1009 now ([§5](aspects.md)). Guards, each proven by seeding its - defect: without the refusal 13 build-outcome tests fail, the LAMA errors among them; refusing a - static constructor too fails 2. -4. **EF Core. ✅ Done 2026-09-27.** - - The mapping spike first (spikes/EfMapping): an additive type-mapping-source plugin maps every - value object, with keys, foreign keys, nullable properties and primitive collections, on - SQLite and PostgreSQL, whatever the registration order, and beside a library that replaces - EF's converter selector ([decision 9](adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md)). - - `CodoMetis.TypeKit.EntityFrameworkCore`: `ValueObjectConverter`, the plugin, the - `.Value`/`GetValue()`/`ValueOrNull()` translators, `UseTypeKit()`, and - `AddEntityFrameworkTypeKit()` for an application that builds EF's internal service provider. - - Stored JSON ([§4](ef-core.md)): `StoredJsonConverterFactory` in the base package ([decision 10](adr/0010-stored-json-storedjsonconverterfactory.md)). - - Tests: the model on SQLite, SQL snapshots on PostgreSQL via `ToQueryString`, the default - comparer against `.Value`, and one Testcontainers PostgreSQL round trip covering every - wrapped-type family, stored values the rules refuse, and the translated queries. - - Guards, each proven by seeding its defect: a plugin that stops recognising value objects - fails 33 of 34 EF tests (all but the control), one that drops the column facets fails the - facet test; losing the member or the method translator - fails 4 and 3; a validating `Materialize` fails the stored-row test; a stored-JSON converter - that validates fails the stored value and key tests. The SQL snapshots first failed on the - cast `Convert` produced, which led to re-typing the column ([§4](ef-core.md)). - - Found in the fifth review on 2026-09-27, and fixed: the method translator took any static - one-argument method carrying the public `TranslatedAsWrappedValueAttribute` for the unwrap, so - a hand-marked `Length(this ProbeCode)` became `WHERE o."Code" = '3'`. It now requires the - unwrap's signature ([§4](ef-core.md)). Guard: the hand-marked test fails without the check, and the - `GetValue`/`ValueOrNull` snapshots, now with a record class's `ValueOrNull`, still translate. -5. **ASP.NET Core. ✅ Done 2026-09-27.** - - The measurement spike first (spikes/OpenApiSchemas): a schema transformer reaches a value - object that only ever appears as a property, which the earlier measurement had denied; container - elements and `TryParse`-bound parameters need handling of their own ([§7](openapi.md), [decision 11](adr/0011-openapi-schema-transformer.md)). - - `CodoMetis.TypeKit.AspNetCore`: `OpenApiOptions.AddTypeKit()`, which adds - `ValueObjectSchemaTransformer`. A value object's schema is filled in, keyword by keyword, from - the schema ASP.NET itself publishes for the wrapped type. - - Tests in `test/CodoMetis.TypeKit.AspNetCore.Tests`: a probe host with minimal APIs and MVC whose - document is read per shape (15 value-object properties across the wrapped-type families, nullable, list, array, set, dictionary, - nested containers, bodies, route/query/header/`[AsParameters]`/MVC `[FromQuery]` parameters, a - query array), each against a control that uses the wrapped type, in OpenAPI 3.1 and 3.0; the - host's enum converter and number handling, on the wire too; composition with another - transformer in either order; a host that inlines value objects; a keyword-completeness test over - every `OpenApiSchema` property. - - Guards, each proven by seeding its defect: not registering it fails 66 tests; dropping the - container branch 9, the parameter branch 18, the MVC model-metadata fallback 8; using the - parameter descriptor instead fails the MVC `[FromQuery]` object (4); keeping ASP.NET's `string` - placeholder fails the integer parameters (8); dropping the nullable unwrap 9; copying - `Metadata` 44; overwriting what a container or a keyword already had 1 and 28; sharing - collections 5; assigning a missing keyword (null) 174; without the recursion stop every - document is refused (73); without the cycle refusal the test host dies of a stack overflow. - - Found while building it: assigning `null` to `OpenApiSchema.Const` is not a no-op, it writes - `"const": null`, so every keyword is assigned only when the wrapped schema has one, and the - keyword tests compare the written schema, not the getters. - - Found in the spike and documented, not fixed: ASP0020 (an error) for a minimal-API route - parameter whose value object is declared in the same project ([decision 12](adr/0012-asp0020-documented-suppressed.md)). -6. **Delivery. ✅ Done 2026-09-27**, up to the first tag, which needs the GitHub repository and the - nuget.org policy (release.yml, "Setup, once"). - - Per-package READMEs: `src//README.md`, packed and named by `PackageReadmeFile` from - `src/Directory.Build.props`, so a new package cannot pack without one. `PackageReadmeTests` - packs every shipping project and reads the nuspec and the package. - - `test/consumer-smoke-test.sh [feed]`: packs the five packages (or takes a packed feed) and - builds three consumers outside the repository, with a private `NUGET_PACKAGES`, package source - mapping and the repository's `global.json`. `core` references only `CodoMetis.TypeKit`: Option - and Result work, no Metalama arrives, CMTK0001 and CMTK0002 fire. `layered`: a domain library - on the generators and an app that reaches them only through it; the app uses the generated - members, the transitive fabric generates a value object the app declares, and CMTK0001 fires in - the app with no CMTK0002. `host`: both satellites in a running web host; the OpenAPI document, - route binding, EF mapping, a round trip and the `.Value` SQL. It asserts on output, diagnostics - and the resolved package graph, never on an exit code alone. - - CI (`dotnet.yml`): test, then a pack job (smoke test against the packed feed, SBOMs) and a - smoke-test job that packs its own feed, the two modes a sibling repository found not to be - interchangeable. - - `release.yml`: on a `v*` tag, verify (tag against `Version`, the changelog dates the version, - full suite, pack, smoke test against the packed feed, one CycloneDX SBOM per package), then - publish through Trusted Publishing behind the `nuget` environment's approval, then a GitHub - release with the changelog section as notes and the SBOMs attached. Only the publish job can - mint a token, and it cannot write to the repository. `CHANGELOG.md` and a release checklist in - CONTRIBUTING.md. - - SBOMs exclude the analyzer's `PrivateAssets=all` Roslyn references: unfiltered, the analyzer's - SBOM listed 13 components for a nuspec that declares none (measured with CycloneDX 6.2.0). - - The unlicensed build: fabric spike finding 9 confirmed in a clean container, no license file - and no variable, the whole solution in Release and all 254 generator tests green. - - Guards, each proven by seeding its defect: the smoke test fails when the base package loses its - analyzer dependency (and the `default(Option)` build then succeeds) and when `AddTypeKit()` - does nothing (three OpenAPI assertions); a generators package without its assembly fails at - pack (NU5128), and a non-flowing Metalama in the repository's own build. - `ReleaseWiringTests`, `ChangelogTests` and `PackageDependencyTests` (17 seeds, each failing its - test): a renamed workflow or environment, a token in the verify job, repository write in the - publish job, a dropped tag or changelog check, either smoke-test mode dropped, a non-executable - script, a narrowed SBOM filter, an unpinned tool, a second publishing path, a malformed or - missing changelog section, a dated version with unshipped rules, a satellite without the base - package, a satellite depending on the generators. - - Found by the clean container: the build-outcome tests built their consumer in Debug whatever the - run's configuration, so CI's Release run would have failed 17 of them; the consumer now builds - in the test assembly's own configuration. - - Found by the smoke test: every README returned a bare fault from a method typed - `Result`, which does not compile ([§9](option-result.md)); and a host that has - `Microsoft.AspNetCore.OpenApi` only through the satellite fails with CS9137, because that - package enables its generator's interceptors in `build/`, which NuGet imports for a direct - reference only. The satellite README says to reference it, as the `webapi` template does; a copy - of Microsoft's switch in our own `buildTransitive` would drift from theirs. - -7. **Native AOT. ✅ Done 2026-09-27** ([§11](native-aot.md)). - - `IsAotCompatible` on the three run-time packages, so the trim and AOT analyzers run in every - build and a warning fails it. `AotCompatibilityTests` reads the packed assemblies' metadata; the - compile-time packages are exempt by name, with the reason. - - The consumer smoke test's `aot` consumer: a domain library and a web host with JSON, OpenAPI - and EF Core (compiled model, precompiled queries), published with `PublishAot`. No trim or AOT - warning may name a package or a woven value object, and the native binary is run and asserted - on. The workflows install clang and zlib before it, held by a wiring test. - - Found by publishing with Native AOT, each fixed and guarded: - - `StoredJsonConverterFactory` found its private constructor by reflection, which the trimmer - removed: stored JSON was **validated after all**, silently. It now asks the generated - converter through `IStoredJsonConverterSource`. - - the OpenAPI satellite described **every value object as `{}`**, silently: the trimmer removes - `IValueObject<,>` from a type's interfaces when nothing uses it, and `GetInterfaces()` found - nothing. The satellites now read `GeneratedValueObjectAttribute<,>` ([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). - - `Option`/`Result` refused JSON with the runtime's "missing native code" message instead of - their own: the refusing converter was made with `MakeGenericType`. One object-typed converter - now serves every type. - - the generated JSON converter serialized the wrapped value through the options (20 warnings in - the consumer's assembly), and a source-generated context without the wrapped type refused a - `decimal` value object outright, on the JIT too. It now goes through `GeneratedJson.TypeInfo`. - - the generated parsing of a `[TypeConverter]` type (NodaTime) called - `TypeDescriptor.GetConverter`, which requires unreferenced code. - - EF Core: the converter's read path called an internal helper, which a compiled model - (`dotnet ef dbcontext optimize`) writes out as C# in the application's assembly, where it does - not compile; a precompiled query cast the converter to its design-time type, which the - compiled model does not recreate; a value-object parameter, mapped at run time, hit EF's own - `MakeGenericType` guard composing its JSON reader/writer; and the converter was made with - `MakeGenericType`. - - Guards, each proven by seeding its defect: reverting the generated JSON to the options fails 6 - `SourceGeneratedJsonTests`, and in the smoke test's `aot` consumer the warnings assertion (8 woven - warnings) and the JSON assertions; each reflection or `MakeGenericType` call put back fails the - build under the analyzers, and suppressed with `#pragma` only, the smoke test's warnings - assertion (IL2070 from ILCompiler); without the generated attribute, the OpenAPI host under - Native AOT is `{}` again; dropping `IsAotCompatible` from a package fails - `AotCompatibilityTests` for that package; a missing or late toolchain step fails the wiring test. - - Not caught at run time: a `GetInterfaces()` lookup suppressed with - `[UnconditionalSuppressMessage]`, since the attribute keeps the interface alive ([§11](native-aot.md)). -8. **Pre-release naming and syntax. ✅ Done 2026-09-27.** - - Names ([decision 18](adr/0018-name-per-concept.md)): the wrapped value, the plain value object, the value object's own type - parameter and the delegate parameters each have one name, and the namespaces follow who uses - a type ([§1](packages.md)). Every rename is a compile error for code that used the old name; the analyzer's - metadata-name tests and the generated-surface snapshot moved with them, the snapshot by exactly - the interface rename and nothing else. - - Syntax ([decision 19](adr/0019-concise-implicit.md)): `Result.Success` (was `Result.Ok`), an `Option.None()` marker, `MapError`, - `Bind` from a valued result onto `Result`, `Or(fallback)` (was `Coalesce`), and a - `ToResult(error)` that keeps the value; the marker-lambda overloads and the collapsing `Match` - are gone. Measured first against a replica of the conversions: the marker works in a return, a - conditional beside `Some`, a `Bind` lambda and an `async` method, and the new `Bind` resolves - beside the generic one. The completeness tests forced a case for every new member; seeding - `None` out of the refusal, `MapError` past the uninitialized check, or the command into the - error branch of `Bind` fails 7, 1 and 1 tests. `ToResult` checks its error on either branch, - like the delegates, and a `Some` that accepted a null error fails its test. -9. **Before the first release: rule set, Result pipelines, performance. ✅ Done 2026-09-28.** - - The analyzer rule set ([decision 13](adr/0013-first-release-s-rule-set-cmtk0001.md)): CMTK0003–CMTK0008 beside CMTK0001/0002 ([§10](analyzers.md)). CMTK0004 - ships at Error, which only the first release can do without a major version. - - `Revalidate()` on a validated value object ([§5](aspects.md)): `Create` applied to the value it holds, for - finding the stored values a rule added later refuses. - - `Result` ([§9](option-result.md)): `MapAsync`/`BindAsync` on both shapes, and on a `Task>` a continuation - of every combinator (`MapAsync`, `BindAsync`, `MapErrorAsync`, `TapAsync`); `Zip` for two to six - results; `Sequence` and `Traverse`. All stop at the first error. - - Benchmarks (`benchmarks/`), against the wrapped types, and a spike against Vogen - ([spikes/ValueObjectPerformance](../spikes/ValueObjectPerformance/README.md)). - - Found by the benchmarks, and fixed: `TryFormat` boxed the wrapped value (32 bytes, 3.5 times a - `Guid`'s time); comparing an enum-backed value object boxed both operands (sorting 1,000 took 8 - times as long and allocated 367 KB); the JSON converter wrote and read a number through a nested - serializer call (1.35–1.40 times). All at parity after the fix ([§5](aspects.md)). - - Found while measuring the rules on woven code: Metalama runs analyzers on the source before - weaving, where a call to a generated member of a value object in the same project does not bind, - so CMTK0003 missed an ignored `TryFrom` and CMTK0007 every `FromKnownGood` ([§10](analyzers.md)). - - Guards, each proven by seeding its defect: the new `Result` members' completeness tests force a - case each (28 null-delegate cases, one uninitialized case per extension, one continuation per - combinator), and six seeds (zips short-circuiting, a delegate checked after the await, `Sequence` - enumerating past the error, `MapAsync` calling its selector on an error, a combinator without a - continuation, the second error first) fail 5, 16, 3, 2, 3 and 1 tests. Each rule's verifier tests - have a positive control per form and a negative control per exemption; 29 seeds across the six - rules each fail their test. A `Revalidate()` that skips `Create` fails both of its behaviour - tests, one of them against PostgreSQL. CMTK0004's exemption of EF's compiled model is measured by the smoke - test: with generated code analysed, the AOT consumer fails with six CMTK0004 errors in - `CompiledModels/OrderEntityType.cs`. The build-outcome consumer holds every rule to a value object - of its own project; without the unbound-call path CMTK0003 and CMTK0007 fail there. The - performance fixes are held by allocation tests on a Debug build, where the JIT never removes a - box, and a number-handling matrix; reverting any fix fails them. -10. **Release readiness for 1.0.0. ✅ Done 2026-09-28.** Four independent audits (the generators, the - satellites, the analyzers, the documentation), each confirming findings empirically in throwaway - consumers of packed packages, and the fixes below, each with a test that fails with the fix reverted. - - Release: the first release is 1.0.0 ([decision 21](adr/0021-first-release-1-0-0.md)); package tags, copyright and release notes, - held by `PackageReadmeTests`; the analyzer's Roslyn floor measured and held ([decision 25](adr/0025-analyzer-s-roslyn-floor-stays-5.md)), and the - build-time `Microsoft.CodeAnalysis.Analyzers` on 5.9.0; registration extensions in the framework - namespaces ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)), which the smoke test's consumers use without a `using`. - - `Option`/`Result`: `Ensure`, `TapError` and their continuations; query syntax with more than - one `from` (the one-selector `SelectMany` made the second `from` a compile error); - `GetValueOrNone`; `FirstOrNone()`/`LastOrNone()` without a predicate; `FirstOrError`/`LastOrError` - check their error on either branch, as `ToResult` does. The completeness tests forced a null and - an uninitialized case for each; six seeds fail seven tests. - - Generators ([§5](aspects.md)): a hand-written `ToString()` was replaced silently (a secret printed); a value - the wrapped type could not parse was quoted in the exception; the JSON differed from the wrapped - type's for `TimeOnly`, date keys, host converters and key policies; an enum-backed value object - allocated 4.7 KB per JSON value; `Uri` parsing disagreed with JSON; `ToType` failed the identity - conversion; nine declarations failed as Metalama errors (CMTK1005, CMTK1010, CMTK1011 now). Guards: - `ToStringSeamTests`, `UnreadableInputTests`, `JsonParityTests`, the allocation and parsing tests, - 18 build-outcome rows. - - Analyzers ([§10](analyzers.md)): nothing fired in Razor; `OrDefault()` and `FirstOrDefault` handed out defaults - unreported (CMTK0009); false positives on guard checks and zero-length arrays; false negatives - in the declaring project, for `x!`/`(x)`/named arguments, static comparisons, and a `Task` of a - result converted to `Task`; CMTK0001 35% faster. 42 verifier tests and six build-outcome cases - added, every seed recorded failing. - - Satellites ([§4](ef-core.md), [§7](openapi.md)): `.Value` on a collection element failed to translate; a key over an integer - lost its identity column ([decision 23](adr/0023-value-object-key-over-integer-generated.md)); nested value objects shared an OpenAPI component - ([decision 24](adr/0024-nested-value-objects-named-after-nesting.md)). The smoke test's AOT consumer inserts through an identity key. - - Documentation: every C# block of the READMEs is compiled and its stated outputs asserted ([§6](test-strategy.md), - item 6); four samples did not compile, one SQL comment was wrong, and a dozen claims were wrong - or untested (a JSON `null` refused by a record class, configuration binding under the source - generator, a JSON 400 with a message from a minimal API, "symbols for every package", …). Each - is now corrected, or backed by a test that did not exist. - - Found and documented, not fixed: a property absent from a JSON body stays `default` (System.Text.Json - calls no converter; `required`/`RespectRequiredConstructorParameters`, now in the ASP.NET README - with a test); `ConfigureConventions` for the wrapped type does not reach a value object; a - validation attribute on a value-object property lands in the shared OpenAPI component. - - Upstream: the Metalama issue behind CMTK1010, reproduced with Metalama.Framework 2026.1.28 and - 2027.0.3-preview by a five-line aspect that introduces `ToString` with `OverrideStrategy.Override` - into a `partial record struct` declared in two parts (LAMA0611 CS0111, then LAMA0613). Lift - CMTK1010 once Metalama emits the override once. - -11. **Second review, against the packed 1.0.0. ✅ Done 2026-09-28.** Five reviews (the generators, - `Option`/`Result`, the analyzers, the satellites, packaging and release), each building throwaway - consumers of the packed packages with a private package cache, and the fixes below, each with a - guard seeded to fail. - - Analyzers ([§10](analyzers.md)): where Metalama compiles a project, source generators run after its - transformation (Metalama 2025.0), and an analyzer sees only the source. Every rule was silent in - the `.razor` files of the usual Blazor layout (a web project referencing a value-object project) - and missed forms in the declaring project. The analyzer package's `buildTransitive` props lists - the analyzers' namespace as a `MetalamaTransformedCodeAnalyzer` item ([decision 33](adr/0033-analyzers-run-code-metalama-transformed.md)). - `AnalyzerPackagingTests` holds every analyzer to it; the smoke test's layered consumer gains a - Razor library and a guard in Domain, and both build clean with the props left out. Every rule - has a `helpLinkUri` (`AnalyzerHelpLinkTests`). The README adds `[RequireCustomInitialization]` for - a struct made of value objects, and `.globalconfig` for severities in Razor, which no - `.editorconfig` section reaches (measured). - - `Option`/`Result` ([§9](option-result.md)): `Result` lost its `bool` conversion ([decision 29](adr/0029-only-result-terror-converts-bool.md)); - `TapErrorAsync(Func)` on both shapes and both continuations, since an `async` error - callback ran as `async void` and its exception ended the process ([decision 30](adr/0030-asynchronous-callbacks-return-task.md)); `TryCast` on - `object?` removed ([decision 31](adr/0031-no-extension-object-unconstrained-t.md)). CMTK0003 treats the instance `TapErrorAsync` like `TapAsync`. - Five seeds fail twelve tests. - - Generators ([§5](aspects.md)): state besides the wrapped value is CMTK1012 (a `Currency` lost in JSON, a lazy - cache that made equal instances unequal, `required` as LAMA0611 CS9035); a hand-written equality, - `PrintMembers` without `ToString()`, and explicit implementations of the interfaces the - generators implement are CMTK1011, or CMTK1008 for comparison (an explicit `IParsable.Parse` - let a generic `T.Parse` past `Create`); a declared `IConvertible` is CMTK1011 (it was LAMA0041); - a `file`-local value object below a directive or nested in a `file` type is CMTK1005 (it was - LAMA0001, failing the whole project); an explicit `Create` beside a public one is CMTK1011, since - a generic `T.Create` reached a second rule set; and what a base record declares counts as the - value object's own (an unsealed `ToString()` on the base was replaced and printed the value). - The first CMTK1012 refused every record base from another assembly (its compiler-generated - `EqualityContract`); the check now skips `[CompilerGenerated]` members. Decision 32. - - Satellites ([§4](ef-core.md), [§7](openapi.md)): a container of nullable value objects, and a nullable value object inlined - by `CreateSchemaReferenceId`, admit null as the wrapped type does, in 3.1 and 3.0 ([decision 36](adr/0036-nullable-value-object-admits-null-openapi.md)); - `InlinedValueObjectTests` compares 34 positions with the wrapped type. On SQLite an integer - value-object key is not `AUTOINCREMENT`, so the model never matched its snapshot and `Migrate()` - threw: the README documents `UseAutoincrement()`, and `MigrationSnapshotTests` checks it - ([decision 35](adr/0035-sqlite-s-autoincrement-application-s-line.md)). The READMEs state the 10.0.12 floors (NU1605 below them), `AddTypeKit()` per - document or `ConfigureAll`, that `UseTypeKit()` needs a relational provider, and - that value objects with one name in different namespaces share a component, as any type does. - - Packaging ([§6](test-strategy.md)): the SBOMs also exclude what the SDK adds (`Microsoft.NET.ILLink.Tasks`, - `NETStandard.Library`), read from restore output (`suppressParent: All`); the ASP.NET Core - satellite drops `Microsoft.AspNetCore.OpenApi.SourceGenerators`, which had baked ten unused types - into it (assembly 229 → 37 KB), held by `GeneratedCodeTests`; symbol packages are pushed in a step - of their own, since a duplicate .nupkg made NuGet skip its .snupkg on a re-run; package - descriptions are plain text, and the release notes omit the changelog heading. - - Analyzer gaps, closed before the tag: CMTK0003 reports a collection of results the statement - made (`Task.WhenAll`, `Select(…).ToList()`) and a method group of a `Task` method - converted to a delegate returning `Task` (14 seeds); CMTK0008 reports a LINQ join on two - different value objects' values, query and method syntax (8 seeds); CMTK0009 reports `Find`, - the immutable collections' own `…OrDefault`, and the async forms of `System.Linq.AsyncEnumerable` - and EF Core (7 seeds). CMTK0001 no longer reports a `default` passed to a comparison guard or an - assertion, or assigned to the `out` parameter of a `bool` Try method (11 seeds). - - Refusals, after a reviewer's note that the refusal-heavy design will draw "why does this throw?" - reports: every deliberate run-time refusal ends with a link to its subsection of the base README's - "Why does this throw?" (`Refusals`, `RefusalLinkTests`, 38 refusals), the two raised by generated - code (`From(null)`, a JSON null) through `CompilerServices` helpers; every CMTK1000–CMTK1012 - message ends with a link to the Generators README's Build errors table, since a Metalama 2026.1 - `DiagnosticDefinition` takes no help link (`BuildOutcomeTests`). `OrNull()` for an `Option` of a - value type (`ValueTypeOptionExtensions`), so the JSON refusal's advice compiles for `Option`. - A nullable `Option`/`Result` is refused with its own message, null or not: writing null threw - `NullReferenceException`, and reading an option gave the `Result` advice. A source-generated - context cannot refuse `Option?` cleanly (it would need `MakeGenericType`) and rejects the - contract when it builds it; the README says so. Decision 37. - - Documentation: the root README gains "How it compares" (when TypeKit fits, a decision table of - needs another library meets better, and detail tables against Vogen, Thinktecture, - StronglyTypedId, LanguageExt, CSharpFunctionalExtensions and ErrorOr, each claim read from source - at the named release on 2026-09-28, the next major in preview included) and "Built with Metalama". - The Generators README links to the comparison. Both are GitHub-only and can be corrected without - a release. - - Found and left for 1.x: CMTK0003 over `.Wait()` and `await foreach` (closed in 2.0.0 before the - rule became an Error, [decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)); CMTK0008 over tuple `==`, `Contains` and `is var`, and - composite join keys (a Warning, [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)). Raw Metalama errors - for a value object named after a generated member (`From`, `TryFrom`, `MinValue`), a wrapped type - whose `Parse` does not return it or that is abstract, and a public base member whose signature - matches a generated one (LAMA0500). `ToString()` of a default `Uri`/`Version` value object throws; `IConvertible` quotes - the value; `class Email : IValue` reports one of its three errors at a time; `IValue` - writes `{}`. JSON on `Option?` throws the wrong exception; a default marker converts. - `UseTypeKit()` on a non-relational provider could refuse in `Validate`. -12. **1.5.0: the boundaries. ✅ Done 2026-10-11.** Every item is additive, so it is a minor version. The - scope follows a survey of the competing libraries' trackers on 2026-10-11 (demand is small - everywhere, at most 22 reactions on one request), keeping what no library ships or what only - preview packages serve, and what follows from what TypeKit already holds to. - - **A `Result` as an HTTP response**, in `CodoMetis.TypeKit.AspNetCore`: a success as its value, a - fault as RFC 9457 problem details, with its status mapped from the fault in the host, so the - domain never references ASP.NET; and the endpoint's responses in the OpenAPI document. Mapping a - result to a response is not serializing it, so [§9](option-result.md)'s refusal stands. The most-requested open item - in the field (FluentResults #149); the packages that answer it are previews or unmaintained. - Measured 2026-10-11 in [spikes/ResultHttp](../spikes/ResultHttp/README.md), under JIT and Native AOT, - minimal APIs and MVC: an explicit `ToHttpResult()` returning ASP.NET's own - `Results, FaultProblem>`, a mapping per fault type registered in the host - (`AddFaultResponses`, which calls `AddProblemDetails()`, without which Native AOT writes no - problem), endpoint metadata recording only the fault type (MVC hands `PopulateMetadata` an empty - service provider), an operation transformer adding a problem response per mapped status, and a - startup filter that refuses a host with an endpoint whose fault type has no mapping. Returning - the `Result` itself from a handler is not offered: it documents a `Result`, and a Native AOT host - would need it in its JSON context. **✅ Implemented 2026-10-11** ([decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)): `FaultResponseTests` - (27, against ASP.NET's own `ProducesProblem` as the control, in OpenAPI 3.1 and 3.0, minimal APIs - and MVC), the README's sample and its problem JSON read from the README, and the smoke test's - Native AOT consumer. Fifteen seeds each fail their guard. The weaving review found one guard - vacuous: every host also mapped ProducesProblem controls, so a problem component the package - never added went unnoticed; a host without them now holds it. Without `AddProblemDetails()` only - the `traceId` assertion notices under JIT, so the AOT consumer is its real guard. A handler typed - `IResult` loses the endpoint's metadata, as ASP.NET's typed results do; the README says so. - - **A value object's fault as a validation error.** A body or parameter that `Create` refuses is a - `400` whose problem details name the member and the fault, as ASP.NET Core 10's validation - reports a data annotation, instead of a bare `400` from a `JsonException`. No library does this. - Measured 2026-10-11 in [spikes/ValidationFaults](../spikes/ValidationFaults/README.md): ASP.NET - Core 10's validation never sees a value that failed to bind, a refused route or query value has - lost its fault (`TryParse` says only `false`), and MVC already puts the converter's message in its - validation problem. What remains is a minimal-API body, reachable only with `ThrowOnBadRequest` - turned on for the whole host and an exception handler, and only once the converter's refusal - carries its fault as data. The README's "Binding" said `AddProblemDetails()` gives a refused body - a response body; it needs `UseStatusCodePages()` too, which it now says. **Dropped 2026-10-11** - ([decision 39](adr/0039-value-object-s-fault-does-become.md)). - - **`CodoMetis.TypeKit.Testing`**: assertions for `Option`/`Result` (`ShouldBeSome()`, - `ShouldBeError(fault)`), which a type without `.Value` needs more than any other, and generators - of valid and refused values that go through `Create`. **✅ Assertions implemented 2026-10-11** - ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)): `AssertionTests` (23), the README's block run as a test and its quoted failure read - from the README, the smoke test's core consumer installing the package. Generators are not built - ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)). - - **Sensitive value objects**: a marker that keeps a value out of `ToString()`, the debugger and - logs (`Microsoft.Extensions.Compliance` classification, structured-logging destructuring). It - extends [decision 27](adr/0027-no-refusal-quotes-input.md) from refusals to every place a value is printed. No library does this. - Measured 2026-10-11 in [spikes/SensitiveValues](../spikes/SensitiveValues/README.md): the - `ToString()` seam the generators keep already masks a value in every formatting path and every - Microsoft.Extensions.Logging path, `[LogProperties]` included, so a marker would generate the one - line a user writes. A classification on the type makes the logging generator refuse the method - (LOGGEN035). The one leak is Serilog's `{@x}`, which destructures the public `Value` of every - value object, masked or not; a destructuring policy closes it. **✅ Implemented 2026-10-11** - ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)): no marker; the Generators README's "A value that must not be printed", and - `CodoMetis.TypeKit.Serilog`. - - **`Recover` on `Result`**: the error branch back to a success, which today needs `Match`. - **✅ Implemented 2026-10-11** on both shapes, with `RecoverAsync` (an asynchronous selector, and - continuations on a pending result). It returns a `Result`, so a selector recovers from some - errors and returns the others; the completeness tests (continuations, null delegates, - uninitialized results) found every new member until each had its case, and four seeds each fail - their guard. - - Candidates once those ship, each with its own measurement: a Dapper satellite (nothing registered - per type, through `GeneratedValueObjectAttribute.Accept`), Newtonsoft.Json under [decision 26](adr/0026-value-object-s-json-wrapped-type.md)'s - parity, and configuration binding, which the configuration binder's source generator does not do - for `TypeConverter`/`IParsable` types (dotnet/runtime#83599). The CMTK0003/CMTK0008 gaps above - stay Warnings ([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)); CMTK0003's are closed in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)). - - **Ranges are not a TypeKit family**: CodoMetis.ValueRanges models them. A value object over one - (`IValidatedValue`) works with both packages as they - ship, measured 2026-10-11 in [spikes/RangeValueObjects](../spikes/RangeValueObjects/README.md): - - it weaves, so phase 11's "abstract wrapped type" gap does not cover a type that parses to itself; - - `UseTypeKit()` composes onto the `daterange` mapping in either order; - - `.Value.Contains(day)` becomes `@>`; - - the generated JSON and OpenAPI mirror `DateRange` exactly. - - No bridge package is needed. ValueRanges 9.0.0 (re-measured the same day) closes the three wire - gaps the value object had inherited: `[JsonConverter]` on every range type, ranges as dictionary - keys, and an OpenAPI transformer to copy, which reaches the value object through `AddTypeKit()`. - The re-run found a TypeKit defect, **fixed**: a value object's `TryParse`-bound parameter took the - wrapped type's JSON schema (`{}` over `DateRange`), where ASP.NET describes a parameter of the - wrapped type as `string` for anything off its own list (`EndpointModelMetadata.GetDisplayType`). - `ParameterTests` has a control for each entry of that list and one off it (`ProbeSpan`). Open: a - README section for the pattern, with no host registration needed for JSON; and CMTK1005 refusing - `RangeSet<,>`, whose equality is a value's, though `IEquatable` alone is no criterion - (`ImmutableArray`). - -13. **2.0.0: closed sets, fault responses, merge patches, exceptions, constraints and text.** In - progress: steps 1 to 9a done (2026-10-13), the preview and the release to come. Scoped 2026-10-12 ([decision 42](adr/0042-2-0-0-next-release-bundles.md)): the features proposed for 1.x ship in the major, - not as a 1.6.0 before it, because the smart-enum family changes what a fault is, and fault - responses built on enums first would be built twice. Pinned 2026-10-13 ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)) to - `net10.0` and Metalama 2026.1: nothing in it needs .NET 11, C# 15 unions or Metalama 2027.0, and - it waits for none of them. The build order, each step a commit with its tests, and a spike - before the part where one is listed: - 1. **Open the release** ✅ 2026-10-13: `Version` 2.0.0 and a `## 2.0.0 — Unreleased` changelog - section. The package-validation baseline is 1.5.1, the last release (it had stayed at 1.5.0, - which `ChangelogTests` only sees once the version being built moves on), and reports nothing - until the first intended break. After moving a baseline, restore the solution: the conventions - pack with `--no-restore`, and a baseline not yet in the local cache fails every pack. - 2. **The text guards** ✅ 2026-10-13: `StringTextExtensions` and `TextFault` in the base package, - `StringTextExtensionsTests` (the character table, pinned by code point), the README section - "Written text" with its two compiled samples. No spike. - 3. **`Result.Catch`** ✅ 2026-10-13, after [spikes/ResultCatch](../spikes/ResultCatch/README.md). - The spike settled the shape: the async trap is an `[Obsolete(error: true)]` overload over a - task-returning delegate, more specific than `Func`, so the compiler refuses it and names - `CatchAsync`; its cost is that a lambda that only throws must state its return type. No - `ValueTask` overloads: beside `Func>` they are ambiguous for every `async` lambda, as the - README already says of `MapAsync`. `Catch` spells both type arguments, and the - one-argument `Catch(Action)` is the command form. A delegate that returns null throws - rather than being caught: the value is computed inside the `try`, the success built outside it. - 4. **CMTK0011 and its code fix** ✅ 2026-10-13: `[RequireParameterlessConstructor]` and - `ConstructorAccessibility` in the base package, `RequiredParameterlessConstructorAnalyzer` in - `.Analyzers`, the fix in `CodeFixes/`, a project nested in the analyzer project's folder and - packed into the same package under `analyzers/dotnet/cs`: an analyzer assembly must not - reference Workspaces (RS1038), and a sibling under `src/` would be a package of its own to the - conventions. The fix spells the id and the property name it reads, held to the analyzer's by a - test. Named `Require…`, beside `[RequireCustomInitialization]`, since the rule requires and the - fix writes; nothing is introduced. - 5. **CMTK0010** ✅ 2026-10-13, after [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md), - run with the real rule over a library, a consumer and a Razor component: every seeded breach - reported once, the forwarding hole closed (a type parameter carries the attribute, or `struct` - and `new()` prove `[Sealed]`/`[Concrete]`), nothing for the open generic in its own body. The - attributes are `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` in the base package; a - generic name is checked where it is written, at the argument, and a call with inferred type - arguments at the call. - 6. **`Patch`** ✅ 2026-10-13, after [spikes/MergePatch](../spikes/MergePatch/README.md), in the - base package and the ASP.NET satellite. The spike settled the AOT shape: a converter type built - with `MakeGenericType` has no constructor to invoke under Native AOT, a static field read by - reflection is trimmed, an open generic converter in `[JsonConverter]` is refused by the source - generator (SYSLIB1220); what works is a registry the closed `Patch`'s static constructor - fills, which the factory runs for the type asked for (`RunClassConstructor`, one IL2059 - suppression with that measurement as its reason). A primitive inside a patch takes the - serializer's built-in converter, since the source generator never sees what a patch wraps; a - class is listed in the context. The document inlines a patch as its argument's schema, never - required, always admitting null; a value object inside a patch is described through what it - wraps, since a source-generated host has no contract for it. - 7. **The closed-set family** ✅ 2026-10-13, after [spikes/ClosedSets](../spikes/ClosedSets/README.md), - which answered the compatibility question: a generated type with static members on Metalama - 2026.1 and `net10.0`, nothing newer needed. `IClosedSet` in the base package, - `ClosedSetAspect` in the generators (applied by the same transitive fabric), CMTK1013–1021, the - discovery attribute `GeneratedClosedSetAttribute` with a typed visitor, a text column in - the EF satellite and a string enum in the OpenAPI satellite. The fault bits (a kind and a - sentence per member) are step 8's, since the fault responses read them. Two reviews the same - day: the first's fixes are CMTK1019–1020 and the kept `ToString()`; the second's are CMTK0012 - (arguments by position), CMTK1021 (a member named as a generated member, or two sharing a - `Match` parameter), null delegates refused before a branch is picked, dictionary keys, and - `ClosedSetConverter`: the compiled model wrote the internal mapping's helpers out as C# - in the application (CS0122), so no closed set reached Native AOT. The smoke test's `aot` - consumer now publishes a set's JSON, its column, a parameter and a JSON column. - 7a. **The research picks** (2026-10-13): a research agent reviewed what 2.0.0 lacks against the - peers and the issue trackers, and the user took its top five, each a commit: - `FirstOrNone`/`FirstOrError` on a query and EF's `FirstOrNoneAsync` ✅ ([decision 47](adr/0047-firstornone-query-runs-query.md)); a closed - set's `[TypeConverter]` and `IParsable` ✅ (explicit, since `Parse(string)` is its surface; the - reflection binder now binds a set; the binder's source generator reads no type converter, - dotnet/runtime#83599); Serilog for closed sets ✅ (measured first: `{@Tier}` logged - `ProbeTier { Rank: 2, Perk: "Priority support.", … }`); continuations of a - `Task>` ✅ (the `Task` set's shapes: no `OrAsync`, no `MatchAsync`, no - `ValueTask` receiver); `Transpose`, `SingleOrNone` and `ElementAtOrNone` ✅ (more than one is a - refusal, as `SingleOrDefault`'s; a negative index on a query is `None` without a query, since - `Skip(-1)` skips nothing). - 8. **The fault responses** ✅ 2026-10-13, after [spikes/FaultResponses](../spikes/FaultResponses/README.md), - whose candidate was the satellite itself and whose thirteen claims are its tests and the smoke - test's AOT consumer. In the base package: `FaultKind`, `IFault` and `IFaultSet` (a kind - and an optional description per member), and `Fault` (a reason and a detail, refused - by JSON and by CMTK0001 when `default`). In the ASP.NET satellite: one internal `FaultType` - for an enum and a closed set alike, `FaultResponse` (status, `type`, `title`), - `AddFaultResponses()` by kind, `WithFaultStatus` and `[FaultStatus]`, - `ToHttpResult(success)`, `IFaultFeature` and `FaultResponseOptions.QualifiedFaultNames` - ([decision 49](adr/0049-fault-responses-client-can-act.md)). `FaultProblem`'s constraint relaxed to `notnull`, the first difference - the 1.5.1 baseline reports, suppressed alone ([decision 48](adr/0048-intended-break-suppressed-alone-baseline-stays.md)). - 9. **The breaks** ✅ 2026-10-13 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)). CMTK0003 is the one Warning raised to Error, after - its gaps were closed: `Wait` on a task the expression made, tasks of results made in the - statement converted to plain ones (`Task.WaitAll`, `WaitAny`), a `foreach` or `await foreach` - whose body never reads the results its header made, and a lazy sequence of results a call made, - materialised and dropped (eleven seeds, each failing its guard; an explicit cast to plain tasks - stays silent, as a cast to `Task` does). The release-tracking file - records the change (RS2001 refuses a descriptor that disagrees, RS2007 a change that is none), - `AnalyzerHelpLinkTests` holds the README's severity column to every descriptor, and the - build-outcome consumer no longer raises CMTK0003 itself, so its reports there prove the - default. CMTK0010–0012 needed nothing: they ship at Error in this major and report only on - what it adds. The 1.5.1 baseline, run over all seven packages with the attribute and - parameter-name rules on as well, reports the one suppressed difference and compiler-emitted - attributes only. - 9a. **A review's findings** (2026-10-13), each a commit: - - **`Catch` over a task** ✅: the command form `Catch(Action)` took a delegate - returning a task and discarded it, an `async` lambda as `async void`; a task spelled as the - type argument (`Catch, …>`) and an inferred `ValueTask` were a success holding the - task. `Catch(Func)` is refused at compile time, and so is - `Catch(Func)`, any value the command form would discard, which is the - only overload a `ValueTask` reaches (the spike's ambiguity rules out `ValueTask` - overloads beside `Task` ones: measured again, every `async` lambda became CS0121). A task - as the value, spelled or inferred, is refused at run time before the delegate runs, and in - `CatchAsync` a task whose value is a task. Two-type-argument refusals were measured and - dropped: they make a lambda that only throws, or returns `null!`, ambiguous, and the calls - they would refuse are already the compiler's conversion errors. - - **A patch's schema refused or admitted the wrong values** ✅, measured by validating values - with JsonSchema.Net (test-only) against the document, 3.0's `nullable` read as 3.0.3 defines - it: a string enum's patch was `"type": "null"` beside its names and admitted nothing, a - closed set's listed no null in its enum and refused it, and `Patch` was - `"type": "null"` and admitted only null. Null now joins what restricts the values: the - enum's list, the type, or, for a schema combining subschemas, a branch beside them. - ASP.NET's own polymorphic schema needed no branch, its derived schemas stating no type; a - host's `oneOf` of typed schemas does, and guards it. - - **CMTK0010 over a method group** ✅: `Action run = Runner.Run;` infers the - type arguments from the delegate, with no generic name written and no call, and was - silent. Every method reference is checked as an inferred call is, at the group; a group - with its arguments written stays the generic name's, reported once. - - **A closed set's `All` was writable** ✅: it was the backing array, so a cast to - `IList` or `T[]` replaced a member that `All`, the JSON schema's enum and every - enumeration then listed, while `Parse`, which reads the fields, answered the original. It - is the array wrapped by `Array.AsReadOnly`, whose writes throw `NotSupportedException`. - - **Invisible text passed `AsText()`** ✅: a value of joiners alone (`"\u200D"`), of variation - selectors alone (filed as non-spacing marks, so no category test reached them), or of a - lone combining mark rendered nothing and was text. A value now needs a base character, and - a variation selector stands only directly after one, so emoji presentation, a flag, a - keycap and an ideographic variant survive; a joiner is not placed, since an Arabic letter - shown in its joining form on its own ends with one. - - **The docs** ✅: the README no longer lists fault responses (documented in the ASP.NET Core - README and guide since step 8) or public aspects (none planned) as still to come; - `docs/migrating-to-2.md` lists what builds or runs differently for 1.5 code, compiled like - every page; and the Native AOT page states the query helpers' limitation, which the smoke - test's `aot` consumer now measures: the base package's `FirstOrNone` on a query and the - satellite's `FirstOrNoneAsync` throw "Query wasn't precompiled", and the operators written - at the call site run. - 9b. **A second review's picks** (2026-10-13): two outside reviews of the release were checked - against the code (one named `Choose`, result aggregation and a dictionary lookup as missing, - where `Zip`, `Sequence`, `Traverse` and `GetValueOrNone` exist, and read CMTK0006 as a `.Value` - guard), and the user took what held, each in this release: - - **`Choose`** ✅: on a sequence with a selector answering an option, and on a sequence of - options; lazy, the arguments checked at the call (F#'s `choose`, CSharpFunctionalExtensions' - `Choose`). - - **`Patch.Map` and `Traverse`** ✅: a patch over what the request sent, validated into a - patch over the domain's type with `Create`'s own fault, a clear and an absence passing - through, `Option.Traverse`'s pattern; a `Map` selector answering null is refused, since - a clear is the Null state and never a value. - - **`TraverseAsync`** ✅, sequential: one operation awaited at a time, in order, nothing - started after the first error; the token overloads check it before each operation and hand - it on, and a cancellation propagates, as `Catch` lets it. Concurrency stays explicit, - `Task.WhenAll` and `Sequence()`. - - **Patch assertions** ✅ in `.Testing`: `ShouldBeValue()` hands the value back, - `ShouldBeNull()` and `ShouldBeAbsent()` name the state, never the value. - - **A closed set parsed ignoring case** ✅ ([decision 51](adr/0051-closed-set-parses-ignoring-case-only.md)): `Parse(name, ignoreCase)` and - `TryParse(name, ignoreCase, out member)`, opted into at the call; two members whose names - differ only in case are CMTK1021, which subsumes the `Match`-parameter collision. - - **The README** ✅ refreshed for 2.0.0: the status, the comparison tables' version, the pitch - naming closed sets and the release's other additions, and a closed-set comparison against - Ardalis.SmartEnum 8.2.0, Intellenum 2.0.1 and Thinktecture.Runtime.Extensions 10.5.1, read - from source and nuspecs on 2026-10-13. - - Not taken: error accumulation and a `Combine` ([§9](option-result.md) non-goals). A stable external name for a - closed-set member, apart from its C# identifier, touches parsing, JSON, EF, OpenAPI and fault - identifiers at once and needs a spike first; it is 2.x. - 9c. **An outside review before the tag** (2026-10-13, a separate Opus 5.5 session over the three - commits and the library), its must-fix findings each fixed with a seeded test: - - A closed set in two partial files listed the second file's members as null in `All` (a field - initializer runs with its own part); `All` is filled by the names initializer, after every - part's fields. Two nested sets of one simple name shared a `QualifiedName` prefix and one - OpenAPI component; both are the nesting chain. A non-public static field of the set's type - was an instance that is no member; CMTK1017. The nested converters are sealed. - - A closed set was a string enum as a body property only: a route or query parameter was a bare - `string`, a container's element was missing. The transformer takes the value-object - transformer's three positions. - - `ElementAtOrNone(^n)` on a query read every row through the sequence overload; refused as - `LastOrNone` is. `fault == reason` on a `Fault` compiled and was false with a detail; - the mixed forms are refused, naming `Reason`. `CatchAsync` over a delegate returning no task - was an error holding a `NullReferenceException`; it throws before awaiting. - - CMTK0010 reported doc-comment crefs and called arrays not sealed; a method implementing or - overriding a constrained method without the attribute let a call through the class pass. - CMTK0003 missed a result task picked by a conditional or a `switch` expression and a method - group answering a collection of results: [decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)'s "complete" was not, and is now - measured by those forms too. - - Documentation: the claims the review found misleading corrected (a set as a parameter, the - `Index` form on a query, "everything works" under Native AOT, `AsText()` and line breaks, - `CatchAsync` over a null task, the `ignoreCase` overloads and binding, the Testing messages' - error, the EF limitations' count), the migration guide's name list completed, `Fault` under - "Not wire types", `WithTitle`, the command `ToHttpResult(success)` and `[FaultStatus]` on a - class documented, the package descriptions naming closed sets, and the prose it flagged. - - Can wait for 2.1, recorded here: the fault name is sent even for a member without a - description; `Uri.ToString()` unescapes the problem `type`; the CMTK0011 code fix on a partial - type with a primary constructor elsewhere, and its doc comment beside an attribute; CMTK0003 - over `WhenAny` of held tasks; `Choose`'s `notnull` receiver; `Patch` under `Traverse`. - 10. **`2.0.0-preview.1` to nuget.org** once steps 7 and 8 exist, the consumer smoke test - against it, then the release. - - **A closed-set (smart enum) family**, declared by interface and joining `.Generators` ([§1](packages.md)), the - release's headline. A member is a named instance with constant data. The generators emit `All`, - parsing and formatting by name, JSON, an OpenAPI string enum, and a `Match` with one required - delegate per member. C# never proves an enum `switch` complete; it only warns about a missing - discard (CS8524). As a fault type, the family carries what `FaultKind` (proposed 2026-10-11, - folded in here) would have read from attributes: - - **A kind per member**: NotFound, Conflict, Invalid, Forbidden or Unavailable, nothing - HTTP-specific, from which `AddFaultResponses` derives a status without a hand-written switch. - It is a property set where the member is declared, so Native AOT reads it without - reflection. The explicit mapping still overrides it, so [decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s switch remains the - escape hatch. - - **An optional sentence per member**: the fault's remedy as a person reads it. It is optional - because a refusal that must not explain itself, a credential's or a secret's, has none on - purpose. - - **A generated qualified name** (`OrderFault.NotFound`) beside the bare one, so two fault types - that share a member name stay apart in one client. - - A smart enum names a fault. It does not replace an error that carries data: a closed set of - shapes (`NotAmendable(Status)`, `OutsidePeriod(Date)`) is what C# 15 unions are for. Translating - one names a smart-enum member, and what varies per occurrence travels as - `Fault.Detail` (below). A spike must answer: - - **Which status for each kind**, and whether a host may change a kind's default. - - **The startup check holds**: a member with neither a kind nor an explicit mapping still stops - the host. - - **A generated type with static members**, not a C# 15 union: 2.0.0 is `net10.0` (decision - 44), and a union-shaped closed set is 3.0.0's question. - - **Persistence**: an EF column by name, mapped by `.EntityFrameworkCore` as value objects are. - - **Fault responses a client can act on**, proposed 2026-10-12. Decision 38's response carries a status and the fault's bare name, and its success is - `200` or `204`. An application whose client shows a refusal to a person, creates resources, or - reads with an ETag has to leave `ToHttpResult()` for each of those endpoints. The libraries - surveyed on 2026-10-12 map centrally too, but none models `Location` or `ETag`: Ardalis.Result - 10.1.0, ErrorOr 2.1.1 and FluentResults. Ardalis and FluentResults allow a per-call status. RFC - 9457 §3.1.1 makes `type` the primary identifier, and §3.1.4 has `detail` help the client correct - the problem. Five additive parts, each opt-in, so [decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s default response is unchanged: - - **A response per member**: `AddFaultResponses(f => FaultResponse.Status(409).WithType(uri))`, - an overload beside the `int` one, checked for every member at registration as that one is. It - adds `type` and `title`, which name the problem type and nothing of the input. - - **A sentence per occurrence**: `Fault(TFault Kind, string? Detail)`, a record struct in - the base package. `Result>` infers `TFault` from a type argument, which an - `IFault` constraint could not, since C# infers nothing from a constraint. The mapping and - the document stay keyed on the enum. The sentence is the application's, so "the problem names - the fault, never the input" becomes the default rather than a guarantee, and the README says so. - - **A status per endpoint**: `.WithFaultStatus(OrderFault.NameTaken, 422)` as endpoint metadata, - read by `FaultProblem`, the operation transformer and the startup check. The same fault can - mean 404 on one route and 409 on another (RFC 9110 §15.5.10 defines 409 by the target resource). - - **The success the endpoint chooses**: `ToHttpResult(order => TypedResults.Created(uri, order))`, - returning `Results>`, so ASP.NET still describes the success. An - ETag needs a result of the application's own, and this is the hook for it. - - **The body the host shapes**: the fault reaches `CustomizeProblemDetails` as a request feature - (`IFaultFeature`: the fault type, the member, the detail), so a host can add extensions such as - an error code without the package naming any, plus an option to qualify `fault` as - `OrderFault.NotFound`, since two enums can share a member name in one client's switch. - - A spike must answer, in [spikes/FaultResponses](../spikes/FaultResponses/README.md): - - **Native AOT** for each part. - - **One reading of a fault.** The mapping, the startup check and the document read a fault type - through one internal abstraction, never `Enum` directly, so an enum fault and a smart-enum - fault (above) take the same path. - - **Inference**: `ToHttpResult()` over `Result>` and over `Result` - resolves without type arguments and without an ambiguity between the two. - - **The document**: a per-endpoint status is listed for that operation only, a `type` URI appears - in it, and `fault` stays a string on the shared `ProblemDetails` component. A per-operation enum - is optional, never a union over every fault type, because a fault type added later would change - every operation's schema. - - **The startup check** refuses a `WithFaultStatus` for a fault type the endpoint does not answer - with, and a member that the mapping, its kind and the endpoint all leave unmapped. - - **MVC**: the endpoint metadata reaches the transformer, where finding 4 found - `PopulateMetadata` handed an empty service provider. - - **`CustomizeProblemDetails` sees the feature** under JIT and Native AOT, and the feature is - absent for every problem `ToHttpResult()` did not write. - - **`Patch`, a field of a JSON Merge Patch (RFC 7396)**, proposed 2026-10-11 for 1.x; built 2026-10-13 (step 6 above answers the questions). It has - three states: absent, null, or a value. System.Text.Json cannot tell an absent property from a - null, so every PATCH endpoint rebuilds this type by hand. It is a wire type on purpose, beside - `Option`, which still refuses JSON ([decision 15](adr/0015-option-result-wire-types.md)): `Option` says whether a value exists, `Patch` - whether a request mentioned it. A spike must answer: - - **`default` is a state.** The serializer calls no converter for an absent property, so - `default(Patch)` has to mean "absent". That makes this the one type of the package whose - `default` is meaningful. Is it exempt from CMTK0001 by its contract, and does that stay - clear of `[RequireCustomInitialization]`? - - **Native AOT.** A converter factory over an open `T` is `MakeGenericType`, which the refusing - converter avoided only because it never reads `T`. Find what works under a source-generated - context: an attribute per closed type, or a generated converter. - - **Value objects.** `Patch`: a JSON null and a value `Create` refuses each give the - response [decision 39](adr/0039-value-object-s-fault-does-become.md) measured. - - **The OpenAPI document.** The property is not required, and admits null exactly where `T` - does, in 3.1 and 3.0. - - **Applying it.** Without reflection over the target ([§11](native-aot.md)): `patch.Name.IfPresent(v => order.Name = v)`, - not an `ApplyTo(entity)` that walks properties. Optionally an analyzer for a `Patch` that is - bound and never read. - - It belongs in the base package, its OpenAPI part in the ASP.NET satellite. It is not ASP.NET - Core 10's JSON Patch (RFC 6902, a list of operations). - - **`Result.Catch`: an exception as an outcome**, proposed 2026-10-13. A call that throws has no - way into a `Result` today, so every caller writes the `try`/`catch` by hand, and a `Try` - type would be `Result` under a second name ([§9](option-result.md)). Four factories on the `Result` - static class, each returning a concrete result rather than a marker, since the delegate fixes - `T`: - - **`Catch(Func)`** to `Result`, and **`Catch(Action)`** to - `Result`, the value-less shape, which is why no `Unit` is needed. - - **`Catch(Func)`** to `Result`, catching that type and - letting every other exception through, so the error type says what was expected to fail. - - **`CatchAsync`** for each, over `Task`, `Task` and `ValueTask`. - - **A cancellation is not an outcome**: `OperationCanceledException` passes through every - overload. Caught, a cancelled request would read as a failed one and `Recover` would run on - it. - - `Map`, `Bind` and the rest stay as they are: a selector that throws propagates, as it does - now, so the one place an exception becomes data is the one that says so. A spike must answer, - in [spikes/ResultCatch](../spikes/ResultCatch/README.md): - - **Inference**: `Result.Catch(() => int.Parse(s))` infers `T`; `Catch` needs - both type arguments spelled, since C# infers all or none, unless a different shape avoids it. - - **The async trap**: `Result.Catch(() => client.GetAsync(url))` binds `T` to a `Task` and - catches nothing the task throws. Whether an `[Obsolete]` error overload, a CMTK rule, or - both, refuses it. - - **`ToHttpResult()` over `Result`** is a compile error, since an exception is - not a fault a client acts on. - - **Native AOT**: nothing new is reflected, and the smoke test's `aot` consumer says so. - - **Constraints a type parameter cannot declare**, proposed 2026-10-13. C# has `where T : class`, - `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` or `record`. A generic - base whose type argument must be an abstract record, such as the command or event family of an - aggregate, gets no help from the compiler, and the closed type that violates it compiles. - Attributes on a generic parameter in the base package, `[Abstract]`, `[Sealed]`, `[Record]` and - `[Concrete]`, and one analyzer rule, CMTK0010, reported at every closed use: a base type, an - implemented interface, a field, a parameter, a `typeof`, a call with its type arguments - inferred. An analyzer rather than an aspect, because the violation is at the use site, which - need not compile with Metalama, and the base package is Metalama-free ([§3](discovery.md)); an aspect on the - parameter would see the declaration and never the use. A spike must answer, in - [spikes/TypeArgumentConstraints](../spikes/TypeArgumentConstraints/README.md): - - **Forwarding**: `class Sub : Base` where `Base<[Abstract] T>` is a violation unless `U` - carries `[Abstract]` too, or the hole is the whole rule. - - **Every use site** is reached, inferred type arguments and generic methods' parameters - included, and nothing is reported twice for one site. - - **Reflection** (`MakeGenericType`) is out of reach, and the README says so. - - **Error in a major version** is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md); the rule never ships at Error in a minor. - - **`[RequireParameterlessConstructor]`** (proposed as `Ensure…` 2026-10-13; `Require…` as built, beside `[RequireCustomInitialization]`). A type the host creates by - reflection, an endpoint, a module, an entity configuration, needs a parameterless constructor - of a given accessibility, and C# checks that only through `new()`, which demands public. An - attribute in the base package, on an interface or a base class, and an analyzer rule, CMTK0011, - on every concrete type that implements or derives from it, directly or through another type, - with a code fix that writes the constructor. An analyzer and not an aspect, although an - `[Inheritable]` aspect introducing the constructor was the first shape considered: - - **The check is the feature.** The constructor is one empty line; the value is the error at - the type that lacks it, and that error has to appear in a project that never compiles with - Metalama, where an inheritable aspect is silent, this repository's characteristic failure. - - **A code fix beats an introduction.** An introduced constructor demands `partial` on every - implementer and is invisible in the source; the fix writes it where the reader looks, and a - type that declares no constructor needs nothing, since it already has a public one. - - **What the rule checks**: a parameterless constructor of the required accessibility exists, - or none is declared and public was asked; a record's copy constructor is not one; a wrong - accessibility is reported at the constructor, with the fix changing it; an abstract type or - an interface is not checked, only its concrete descendants. - - **Public aspects stay possible** ([§5](aspects.md)): an aspect that is itself the feature may be public, - with the attribute and its diagnostics as its surface. This one is not the first, because - its feature is the check. - - **Written text**, proposed 2026-10-13. Trimming, refusing the characters that render as - nothing and composing to NFC is written again in every application that stores what a person - typed, and `IsNullOrWhiteSpace` is what it usually gets, which lets a zero-width space, a Hangul - filler and NUL through, and keeps a name written with `ü` and the same name written with `u` and a - combining diaeresis as two rows of a unique index, rendering alike. Two string extensions in the base package, each returning - `Result` with `TextFault.Blank` or `TextFault.Hidden`: - - **`text.AsText()`**, for text a person wrote: trimmed, every space separator folded to - `U+0020`, NFC-composed, every character that renders as nothing refused, the two zero-width - joiners tolerated because they spell Persian, Arabic and Indic scripts and emoji sequences. - - **`text.AsIdentifier()`**, for a value another system issued, a subject claim, a session id: - trimmed, nothing else altered, since canonicalising somebody else's identifier changes an - attested value, and the joiners refused too, since no issuer needs one. - - **A null receiver is `Blank`**, since the extension is the door a bound request property goes - through, and binding enforces no non-nullability. - - **Two faults, not one**, because a required field makes `Blank` an error and an optional one - makes it absence; `Hidden` is a refusal in both. - - **The scan is by `Rune`**: a `char` loop reports `Surrogate` for a character above the BMP and - misses the tag characters used to hide text in text; an unpaired surrogate is `Hidden`, not a - throw, since `Normalize` and a database driver both throw on it. - - **Not refused**: unassigned code points, since .NET's tables lag Unicode and a name spelled - with a newly assigned character would be refused until the SDK caught up; confusable letters - across scripts, which is a comparison-key question and not a rejection. - - No value object ships with them, and no `.Text` package either (weighed 2026-10-13). The base - package is Metalama-free, so it cannot declare one for the generators, and a hand-written one is - what CMTK0002 exists to refuse. A package shipping a woven one would reference `.Generators` - with `PrivateAssets="all"` to keep Metalama away from its consumers, and would need an - exemption in the Metalama-boundary conventions, a package-validation baseline override, a - smoke consumer of its own under Native AOT, and a third state in the README's Metalama column. - All of that buys one line per field, and the type is a dead end: a field that later needs a - length limit cannot wrap it (CMTK1005) and is rewritten over `string`, and two fields of one - shared type lose what CMTK0008 and assignment give two named types. A consumer declares its - own in one line, `Create` being `value.AsText().Map(…)`, and names it for the field - (`Description`, `DisplayName`). The README shows that line. The extensions live in a class - named `StringTextExtensions`, so a consumer's value object named `Text` has no companion-class - collision with them (CMTK1007) and nothing is ambiguous with `System.Text`. If demand for a - ready-made type appears, a minor-version `CodoMetis.TypeKit.Text` holding `WrittenText` and - `IssuedIdentifier` under those conditions is additive; removing one after shipping is not. No - spike: every claim is a unit test over a string. - - **What 2.0.0 breaks** ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)): CMTK0003 at Error, the one Warning raised; CMTK0010–0012 - new at Error; `FaultProblem`'s constraint, suppressed alone against the 1.5.1 baseline - ([decision 48](adr/0048-intended-break-suppressed-alone-baseline-stays.md)); `FirstOrNone`/`FirstOrError` binding to the query overloads, and `LastOrNone` - on a query a compile error. - - **What it does not wait for** ([decision 44](adr/0044-2-0-0-net10-0-metalama.md)): Metalama 2027.0 stable, .NET 11 and C# 15 unions. - The compiler-made union types have a public `object? Value` and are serialized by - System.Text.Json, so `Option`/`Result` take part in their pattern matching only after a spike - shows neither returns, and that spike belongs to 3.0.0. - - **What comes after**: the Metalama 2027.0 upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)) joins the first release after - it is stable, as a minor if a consumer's own aspects beside the packed generators still build - and weave, measured as the fabric spike measured 2026.1 against the 2027.0 preview, and as a - major otherwise; `[Durable]` returns to the `_value` placeholder with it. A `net11.0` target - joins as a second target in a 2.x minor when a feature needs it, at the cost priced on - 2026-10-12: EF Core 11 and Microsoft.AspNetCore.OpenApi 11 in the satellites and a Native AOT - smoke run per target. C# 15 unions and the `net10.0` drop are 3.0.0, timed to .NET 12 LTS - (November 2027) or to the end of .NET 10's life, whichever its consumers need. - - **The preview** (`2.0.0-preview.N`) is 2.0.0's own, from `main`, on the .NET 10 SDK and - Metalama 2026.1, published to nuget.org once the closed-set family and the fault responses - exist, so the surface gets consumer feedback before the tag. The release workflow takes a - preview tag as it is, since `v2.0.0-preview.1` passes the tag check against `Version` and the - changelog check against a dated `## 2.0.0-preview.1` section. The measurement of unions and - Metalama 2027.0 on the .NET 11 SDK moves to a 3.0.0 spike on a branch of its own, with no - release attached. diff --git a/design/plan.md b/design/plan.md index bc72c43..1d490a3 100644 --- a/design/plan.md +++ b/design/plan.md @@ -17,9 +17,9 @@ you touch; the rest is reference. | [Option and Result](option-result.md) | Their shape, the non-goals, the combinators and the async forms | with the area | | [Analyzer package and rule set](analyzers.md) | Packaging, the rule table with severities, and the limits of each rule | with the area | | [Native AOT](native-aot.md) | What is measured, how, and what each package asks of a consumer | with the area | -| [History](history.md) | The phase log from 1.0.0 to 2.0.0 as it was kept until 2026-10-13, with the measurements it recorded | closed | The spikes that decided something are in [spikes/](../spikes/), each with a README stating the question, the answer and the evidence. A spike's README names the decision it led to. -Until 2026-10-13 all of this was one file, `design/plan.md`, and links into its sections land here. +Until 2026-10-13 all of this was one file, `design/plan.md`, with a phase log that git holds +(`git show 4237abb:design/plan.md`) and the changelog summarises; links into its sections land here. diff --git a/design/roadmap.md b/design/roadmap.md index c4d6816..e8e0497 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -1,8 +1,7 @@ # Roadmap -What is open, and nothing else. Done work is in the [changelog](../CHANGELOG.md) and in git, and the -phase log that recorded it until 2026-10-13 is kept as [history](history.md). Each item names the -decision that scoped it. +What is open, and nothing else. Done work is in the [changelog](../CHANGELOG.md) and in git. Each +item names the decision that scoped it. ## 2.0.0, before the tag @@ -14,19 +13,18 @@ decision that scoped it. ## 2.1, additive -- What the outside review of 2026-10-13 rated can-wait ([history](history.md), step 9c): the fault +- What the outside review of 2026-10-13 rated can-wait: the fault name is sent even for a member without a description; `Uri.ToString()` unescapes the problem `type`; the CMTK0011 code fix on a partial type whose other part has a primary constructor, and its doc comment beside an attribute; CMTK0003 over `WhenAny` of held tasks; `Choose` on a sequence with a nullable element; `Patch` under `Traverse` and `Map`. - CMTK1021 groups names ignoring case, which misses the one `Match` collision `ToLowerInvariant` makes and ordinal case folding does not (the Kelvin sign); it fails loudly, as LAMA0611 and CS0100. -- A `context7.json`, once the repository is public ([decision 14](adr/0014-context7-json.md)). ## 2.x, with a spike first - A stable external name for a closed-set member, apart from its C# identifier: parsing, JSON, EF, - OpenAPI and the fault identifiers at once ([history](history.md), step 9b). + OpenAPI and the fault identifiers at once (proposed in the review of 2026-10-13). - The Metalama 2027.0 upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)): a minor if a consumer's own aspects beside the packed generators still build and weave, measured as the fabric spike measured 2026.1 against the 2027.0 preview, and a major otherwise; `[Durable]` returns to the `_value` placeholder with it. diff --git a/spikes/ClosedSets/README.md b/spikes/ClosedSets/README.md index cd0cafb..7e876d9 100644 --- a/spikes/ClosedSets/README.md +++ b/spikes/ClosedSets/README.md @@ -1,7 +1,7 @@ # Spike: can a closed set be a generated type with static members, on Metalama 2026.1 and .NET 10? **Status: run 2026-10-13, answered.** From the "closed-set (smart enum) family" part of phase 13 -(2.0.0) in [the phase log](../../design/history.md). Decision 44 pins 2.0.0 to `net10.0` and Metalama +(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13). Decision 44 pins 2.0.0 to `net10.0` and Metalama 2026.1, so the family is a generated type with static members, not a C# 15 union; this spike is the measurement that nothing in it needs anything newer. diff --git a/spikes/FaultResponses/README.md b/spikes/FaultResponses/README.md index ebca941..cca1ed6 100644 --- a/spikes/FaultResponses/README.md +++ b/spikes/FaultResponses/README.md @@ -1,7 +1,7 @@ # Spike: can a fault response carry what a client acts on, without giving up [decision 38](../../design/adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)'s checks? **Status: run 2026-10-13, answered.** From the "Fault responses a client can act on" part of phase 13 -(2.0.0) in [the phase log](../../design/history.md). The fault's kind is no longer a `FaultKind` +(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13). The fault's kind is no longer a `FaultKind` attribute: it is a property of a closed-set member, in the same phase. **Question.** Decision 38's `ToHttpResult()` answers a fault with its status and its bare name, and diff --git a/spikes/MergePatch/README.md b/spikes/MergePatch/README.md index b55c9fc..bd2b19a 100644 --- a/spikes/MergePatch/README.md +++ b/spikes/MergePatch/README.md @@ -1,7 +1,7 @@ # Spike: can a merge-patch field tell absent from null, read a value object, and survive Native AOT? **Status: run 2026-10-13, answered.** From the "`Patch`" part of phase 13 (2.0.0) in -[the phase log](../../design/history.md). +the former plan's phase log (git, `design/plan.md` before 2026-10-13). **Question.** A JSON Merge Patch (RFC 7396) field has three states, absent, null and a value, and System.Text.Json calls no converter for a property it does not find. Can one struct in the base diff --git a/spikes/ResultCatch/README.md b/spikes/ResultCatch/README.md index 42de4d0..98f6b44 100644 --- a/spikes/ResultCatch/README.md +++ b/spikes/ResultCatch/README.md @@ -1,7 +1,7 @@ # Spike: can an exception enter a `Result` through one factory, without a `Try` type? **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 -(2.0.0) in [the phase log](../../design/history.md), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md). [§9](../../design/option-result.md) keeps `Try` and `Unit` out: +(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md). [§9](../../design/option-result.md) keeps `Try` and `Unit` out: a `Try` is `Result` under a second name, and `Result` is the value-less shape a `Unit` would stand in for. diff --git a/spikes/TypeArgumentConstraints/README.md b/spikes/TypeArgumentConstraints/README.md index 0232917..8d866ed 100644 --- a/spikes/TypeArgumentConstraints/README.md +++ b/spikes/TypeArgumentConstraints/README.md @@ -1,7 +1,7 @@ # Spike: can an attribute on a type parameter constrain its arguments where C# cannot? **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "Constraints a type parameter -cannot declare" part of phase 13 (2.0.0) in [the phase log](../../design/history.md), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md), and +cannot declare" part of phase 13 (2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md), and the CMTK0010 row of §10. **Question.** C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : From 731836a2c204f19d3ce25c0bd6ebbb121a0d6e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:39:33 +0200 Subject: [PATCH 045/147] test: an MVC closed-set parameter is the string enum, through the model metadata The transformer reaches an MVC action's parameter through its model metadata, where a minimal API names the set in the parameter's type; the fallback had no test. A top-level controller, since MVC discovers only top-level public controllers, binds a set from the route and from the query, and both are the enum in 3.1 and 3.0. Seeded: without the model-metadata fallback, both cases fail. Co-Authored-By: Claude Fable 5.1 --- .../ClosedSetSchemaTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs index ec6b514..6c91779 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs @@ -1,11 +1,26 @@ using System.Text.Json.Nodes; using CodoMetis.TypeKit.Generators.Probes; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; namespace CodoMetis.TypeKit.AspNetCore.Tests; +/// +/// An MVC action with closed-set parameters, top-level since MVC discovers only top-level public +/// controllers: the transformer reaches its parameters through their model metadata. +/// +[ApiController] +public sealed class TicketsController : ControllerBase +{ + [HttpGet("/mvc/tickets/{status}")] + public int ByRoute(ProbeStatus status) => 0; + + [HttpGet("/mvc/tickets")] + public int ByQuery([FromQuery] ProbeStatus status) => 0; +} + /// A closed set in the document: a string enum of its members' names, as an enum is described. public sealed class ClosedSetSchemaTests { @@ -77,6 +92,22 @@ public async Task A_container_of_closed_sets_has_their_enum_as_its_elements(Open } } + /// MVC names a bound parameter's type in its model metadata, where a minimal API names it in the parameter's type: both are the enum. + [Theory] + [MemberData(nameof(Versions))] + public async Task An_MVC_closed_set_parameter_is_the_string_enum_too(OpenApiSpecVersion version) + { + var document = await ProbeHost.DocumentAsync(openApi => openApi.AddTypeKit(), version: version); + + foreach (var path in new[] { "/mvc/tickets/{status}", "/mvc/tickets" }) + { + var parameter = document.Resolve(document.Parameter(path, "get", "status")); + + parameter["type"]!.GetValue().ShouldBe("string", $"{path}: {parameter.ToJsonString()}"); + Names(parameter).ShouldBe(["Open", "Shipped", "Cancelled"], path); + } + } + /// Two nested sets of one simple name shared one component, named after the first described, whose names then stood for both. [Fact] public async Task Two_nested_sets_of_one_simple_name_are_two_components() From 2e8436894d18096c2e4fb863a1a5b935562e2b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:41:36 +0200 Subject: [PATCH 046/147] docs: the FluentResults row says what a 2.0.0 Result carries beside one error The cell described FluentResults only, and a reader inferred that a TypeKit Result carries one TError and nothing else, which was 1.0.0. A Fault adds a sentence about the occurrence and Catch the exception; success messages, a list of reasons and a cause chain are still FluentResults' alone. Read from FluentResults' current docs on 2026-10-13; 4.0.0 is still its latest release. Co-Authored-By: Claude Fable 5.1 --- docs/comparison.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/comparison.md b/docs/comparison.md index 4bc3989..28ae936 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -17,7 +17,7 @@ other side, and the detail. | A plain Roslyn source generator in the build rather than Metalama | Vogen or Thinktecture.Runtime.Extensions | TypeKit's generators run on Metalama (open source, no license key) in the project that declares value objects | | Value objects of several members, or discriminated unions | Thinktecture.Runtime.Extensions | It has `[ComplexValueObject]` and `[Union]`. A TypeKit value object wraps exactly one value; its closed sets are the smart enums | | Several errors per failure, of typed kinds | ErrorOr | An `ErrorOr` carries a list of errors. A TypeKit `Result` carries one `TError`, which can be a list of your own | -| Errors with reasons, success messages and causes | FluentResults | Its results carry reasons, errors and successes, and an error can name what caused it | +| Errors with reasons, success messages and causes | FluentResults | Its results carry reasons, errors and successes, an error can name what caused it, and both take metadata. A TypeKit `Result` carries one `TError`: a `Fault` adds a sentence about the occurrence and `Catch` the exception, but there are no success messages, no list of reasons and no cause chain | | A functional-programming framework: `Either`, `Fin`, effects, immutable collections | LanguageExt (4.4.9; 5.0 in beta) | TypeKit's `Option` and `Result` are deliberately small, with no effect or validation types | | Unions of arbitrary types | OneOf | `OneOf` is general-purpose; TypeKit has only `Option` and `Result` | | A `Result` on the wire | CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its `Result`. TypeKit refuses to serialize `Option` and `Result`, by design | From f8ff169d559752c7b3e1bc723641a104583ef250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:42:29 +0200 Subject: [PATCH 047/147] docs: the FluentResults row names what is still its alone, since a reason is a Fault now "Errors with reasons" overstated the gap: a Fault is one reason from a fixed set with a sentence about the occurrence, and a closed-set fault carries a kind and constant data. What stays FluentResults' alone is a cause nested under an error, metadata per occurrence, and success messages. Co-Authored-By: Claude Fable 5.1 --- docs/comparison.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/comparison.md b/docs/comparison.md index 28ae936..385ce17 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -17,7 +17,7 @@ other side, and the detail. | A plain Roslyn source generator in the build rather than Metalama | Vogen or Thinktecture.Runtime.Extensions | TypeKit's generators run on Metalama (open source, no license key) in the project that declares value objects | | Value objects of several members, or discriminated unions | Thinktecture.Runtime.Extensions | It has `[ComplexValueObject]` and `[Union]`. A TypeKit value object wraps exactly one value; its closed sets are the smart enums | | Several errors per failure, of typed kinds | ErrorOr | An `ErrorOr` carries a list of errors. A TypeKit `Result` carries one `TError`, which can be a list of your own | -| Errors with reasons, success messages and causes | FluentResults | Its results carry reasons, errors and successes, an error can name what caused it, and both take metadata. A TypeKit `Result` carries one `TError`: a `Fault` adds a sentence about the occurrence and `Catch` the exception, but there are no success messages, no list of reasons and no cause chain | +| A cause nested under an error, metadata per occurrence, or success messages | FluentResults | Its results carry reasons, errors and successes, an error can name what caused it, and both take metadata. A TypeKit `Result` carries one `TError`: a `Fault` is one reason from a fixed set with a sentence about the occurrence, a closed-set fault carries a kind and constant data, and `Catch` carries an exception as the whole error; nothing attaches a cause to a fault, and a success carries only its value | | A functional-programming framework: `Either`, `Fin`, effects, immutable collections | LanguageExt (4.4.9; 5.0 in beta) | TypeKit's `Option` and `Result` are deliberately small, with no effect or validation types | | Unions of arbitrary types | OneOf | `OneOf` is general-purpose; TypeKit has only `Option` and `Result` | | A `Result` on the wire | CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its `Result`. TypeKit refuses to serialize `Option` and `Result`, by design | From eb51830bc98d6728f6424d8cb93494775826d361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 20:43:38 +0200 Subject: [PATCH 048/147] docs: a Cause on Fault joins 2.1, as decision 52 and a roadmap item An exception caught by Result.Catch is lost on the way to the fault an endpoint answers, so a log of the refusal cannot say what failed underneath. In 2.1, additive: an optional Exception? Cause on Fault, set through Fault.Of(reason, detail, cause), kept for the log and the fault feature and never written to the problem; equality stays the reason's and the detail's. Co-Authored-By: Claude Fable 5.1 --- design/adr/0052-cause-on-fault-joins-2-1.md | 21 +++++++++++++++++++++ design/adr/README.md | 1 + design/roadmap.md | 3 +++ 3 files changed, 25 insertions(+) create mode 100644 design/adr/0052-cause-on-fault-joins-2-1.md diff --git a/design/adr/0052-cause-on-fault-joins-2-1.md b/design/adr/0052-cause-on-fault-joins-2-1.md new file mode 100644 index 0000000..4607d26 --- /dev/null +++ b/design/adr/0052-cause-on-fault-joins-2-1.md @@ -0,0 +1,21 @@ +# 0052. A `Cause` on `Fault` joins 2.1 + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, after the FluentResults comparison was re-read for 2.0.0. + +A `Fault` is one reason from a fixed set and a sentence about the occurrence. What a caught +exception was is lost on the way to it: `Result.Catch` makes the exception the whole error, and the +`MapError` that turns it into the fault an endpoint answers drops it, so a log of the refusal cannot +say what failed underneath. A cause nested under an error is the one FluentResults capability a +TypeKit user plausibly misses; success messages and a list of reasons stay outside what a `Result` +here is ([decision 5](0005-option-result.md), [§9](../option-result.md)). + +In 2.1, additive: an optional `Exception? Cause` on `Fault`, set through +`Fault.Of(reason, detail, cause)`, kept beside the reason and the detail for the log and the fault +feature on the request, and never written to the problem, whose `detail` stays the application's +sentence and whose body names no exception. Equality stays the reason's and the detail's, so a +fault with a cause still equals the same fault without one where a test compares them; the cause +is for the log. A `MapError` from a `Result` that keeps the exception as the cause is +the pattern the docs show. Not a chain: one cause, which may itself be an aggregate exception. diff --git a/design/adr/README.md b/design/adr/README.md index 5c0100d..d989929 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -58,3 +58,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0049](0049-fault-responses-client-can-act.md) | Fault responses a client can act on | 2026-10-13 | accepted | | [0050](0050-warnings-cmtk0003-alone-becomes-error-2.md) | Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0 | 2026-10-13 | accepted | | [0051](0051-closed-set-parses-ignoring-case-only.md) | A closed set parses ignoring case only when asked, and refuses members that differ only in case | 2026-10-13 | accepted | +| [0052](0052-cause-on-fault-joins-2-1.md) | A `Cause` on `Fault` joins 2.1 | 2026-10-13 | accepted | diff --git a/design/roadmap.md b/design/roadmap.md index e8e0497..0ff3f3c 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -13,6 +13,9 @@ item names the decision that scoped it. ## 2.1, additive +- An optional `Cause` on `Fault`, an exception kept beside the reason and the detail for the + log and the fault feature, never written to the problem; `Fault.Of(reason, detail, cause)`, and a + `MapError` from a `Result` that keeps the exception ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)). - What the outside review of 2026-10-13 rated can-wait: the fault name is sent even for a member without a description; `Uri.ToString()` unescapes the problem `type`; the CMTK0011 code fix on a partial type whose other part has a primary constructor, and its From e9aedfc3a695432348c9a8ca8ba14f87cb3bbe72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:13:44 +0200 Subject: [PATCH 049/147] docs: design notes for closed sets and Patch, page links for the old plan's section numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second outside review of 2026-10-13 found no note for the two largest 2.0.0 additions, the aspects note counting ten aspect classes where AspectOrder lists eleven, packages.md without the 2.0.0 surface, test-strategy.md still waiting for 1.0.0, and about 176 references of the form §N that point at sections of a plan that is now a map. Each § reference names its page; closed-sets.md and patch.md say what must stay true and where each satellite meets the type; the weaving review gains the areas that review found most in; and the roadmap's 2.1 holds what it rated can-wait. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/weaving-review/SKILL.md | 42 +++++++++++++- design/adr/0002-name-codometis-typekit.md | 2 +- design/adr/0005-option-result.md | 2 +- design/adr/0006-result-shape.md | 2 +- design/adr/0007-analyzer-own-package.md | 2 +- .../adr/0008-uninitialized-result-throws.md | 2 +- ...tive-irelationaltypemappingsourceplugin.md | 2 +- ...-stored-json-storedjsonconverterfactory.md | 2 +- design/adr/0011-openapi-schema-transformer.md | 2 +- .../adr/0012-asp0020-documented-suppressed.md | 2 +- .../0013-first-release-s-rule-set-cmtk0001.md | 2 +- design/adr/0015-option-result-wire-types.md | 2 +- ...016-native-aot-wherever-platform-allows.md | 2 +- ...eratedvalueobjectattribute-tvalueobject.md | 2 +- design/adr/0018-name-per-concept.md | 2 +- design/adr/0019-concise-implicit.md | 2 +- ...value-object-key-over-integer-generated.md | 2 +- ...ested-value-objects-named-after-nesting.md | 2 +- .../0026-value-object-s-json-wrapped-type.md | 2 +- design/adr/0027-no-refusal-quotes-input.md | 2 +- ...-seams-declared-everything-else-refused.md | 2 +- .../0029-only-result-terror-converts-bool.md | 2 +- ...0030-asynchronous-callbacks-return-task.md | 2 +- ...031-no-extension-object-unconstrained-t.md | 2 +- ...-value-object-holds-wrapped-value-alone.md | 3 +- ...analyzers-run-code-metalama-transformed.md | 2 +- ...ors-fixed-within-major-version-warnings.md | 2 +- ...lite-s-autoincrement-application-s-line.md | 2 +- ...llable-value-object-admits-null-openapi.md | 3 +- ...ument-constraints-constructor-guarantee.md | 2 +- design/adr/0052-cause-on-fault-joins-2-1.md | 2 +- design/analyzers.md | 2 +- design/aspects.md | 11 ++-- design/closed-sets.md | 55 +++++++++++++++++++ design/discovery.md | 4 +- design/ef-core.md | 4 +- design/native-aot.md | 4 +- design/openapi.md | 2 +- design/option-result.md | 2 +- design/packages.md | 17 +++--- design/patch.md | 52 ++++++++++++++++++ design/plan.md | 2 + design/roadmap.md | 13 +++++ design/test-strategy.md | 9 +-- spikes/FabricSpike/README.md | 2 +- spikes/RangeValueObjects/README.md | 6 +- spikes/ResultCatch/README.md | 2 +- spikes/ResultHttp/README.md | 8 +-- spikes/SensitiveValues/README.md | 2 +- spikes/TypeArgumentConstraints/README.md | 6 +- .../CodoMetis.TypeKit.Analyzers.csproj | 2 +- .../ValueObjectDeclaration.cs | 2 +- .../CodoMetis.TypeKit.csproj | 2 +- test/consumer-smoke-test.sh | 6 +- 54 files changed, 240 insertions(+), 79 deletions(-) create mode 100644 design/closed-sets.md create mode 100644 design/patch.md diff --git a/.claude/skills/weaving-review/SKILL.md b/.claude/skills/weaving-review/SKILL.md index 4ccef63..12854b3 100644 --- a/.claude/skills/weaving-review/SKILL.md +++ b/.claude/skills/weaving-review/SKILL.md @@ -1,6 +1,6 @@ --- name: weaving-review -description: Review a change to CodoMetis.TypeKit for code that compiles and silently does less than it appears to — a type that is not generated, an analyzer that stops firing, a generated entry point that bypasses Create, an Option/Result that leaks its content or reads as success, a satellite that falls back to default behaviour. Use when changing Option/Result, an aspect, the transitive fabric, AspectOrder, the analyzer, the EF converter/convention/translators, the OpenAPI transformers, or package references — and before cutting a release. +description: Review a change to CodoMetis.TypeKit for code that compiles and silently does less than it appears to — a type that is not generated, an analyzer that stops firing, a generated entry point that bypasses Create, an Option/Result that leaks its content or reads as success, a satellite that falls back to default behaviour. Use when changing Option/Result, an aspect, the transitive fabric, AspectOrder, the analyzer, the EF converter/convention/translators, the OpenAPI transformers, closed sets, `Patch`, the fault responses, the Serilog or Testing package, or package references — and before cutting a release. --- # Weaving review @@ -23,6 +23,8 @@ does, and what they expected. nested type, a generic type, and a type in a project that reaches the package only transitively. - A type the fabric skips compiles cleanly. Is there a test or a diagnostic that would notice? + An **abstract** type with a marker, and a name that is a C# keyword (`namespace Shop.@event`, a + member `@class`), were both measured once as silent or as a Metalama crash rather than a CMTK error. - A project that references only `CodoMetis.TypeKit` gets no aspects at all. Does CMTK0002 ("marker without `.Generators`") still fire there? - Is `AspectOrder` still complete? A new aspect class missing from it runs in an unspecified @@ -69,15 +71,26 @@ likely. match the snapshot? A translator that stops matching makes EF throw at best, or evaluate on the client at worst. - Is the comparer consistent with the type's equality? +- Does a translator check that the column's converter is **TypeKit's**? A user's own + `HasConversion` stores through their converter; a `.Value` translated as if TypeKit's were there + queries the wrong representation and answers wrong rows. +- Does a hand-written **`CompareTo`** reach the query? EF translates a comparison or an ordering to + the column's order, not the seam's. +- Is an integer key generated on add **wherever EF generates the wrapped type's**: every integer + type EF generates, the root of a hierarchy (TPH, TPT), on every provider the tests run? ## 5. OpenAPI - For a value object used only as a property, as a list element, as a dictionary value and as a route/query parameter: what schema does the document actually contain? Look at the emitted JSON, not at whether the transformer ran. A schema transformer that registered, compiled and - never ran for a property-only type has been measured before (plan §7). + never ran for a property-only type has been measured before ([OpenAPI](../../../design/openapi.md)). - Is the schema the **underlying type's** schema (format included: `uuid`, `date`, `int64`), and does it match what the JSON converter writes? +- Can two types get **one component name**? A name built from a nesting chain (`Order.Status` → + `OrderStatus`) meets a top-level type of that name, and one component then describes both. +- Is every name a client matches on (a fault's qualified name, a component) unique for nested types + of one simple name? ## 6. Option and Result @@ -88,7 +101,7 @@ likely. a branch? - Does every new combinator (`Map`, `Bind`, `Tap`, async variants) preserve the error or None unchanged, and avoid invoking the callback on it? -- Is the change inside the non-goals in plan §9 (no Either, no effects, no LanguageExt drift)? +- Is the change inside the non-goals in [Option and Result](../../../design/option-result.md) (no Either, no effects, no LanguageExt drift)? ## 7. Packaging @@ -99,3 +112,26 @@ likely. - Do the EF and ASP.NET satellites depend on `CodoMetis.TypeKit` only, never on `.Generators`? - Does the consumer smoke test build a project that references the packages **transitively**, the case the fabric spike proved and a refactor could lose? + +## 8. Closed sets, `Patch`, faults, and the run-time satellites + +- **Closed sets:** does anything generated that the set's own static field initializers can read + (`All`, `Parse`, `Name`) answer from a half-built set? Is every member found across partial files, + in declaration order? Does every entry point (JSON, EF, binding, the type converter, `IParsable`) + read ordinally, with ignoring case an overload the caller asks for? +- **`Patch`:** does `default` still read as Absent, null as Null, and does a value object inside a + patch still go through its own converter? Is anything new registered so that Native AOT finds it + without `MakeGenericType`? +- **Faults:** can a `Fault` be compared with a bare reason by any path (an operator, a + conversion, `Equals`, a collection's `Contains`, a test assertion), where a fault with a detail + then never equals it? +- **Serilog:** does a value object or a closed set log as what it prints, and does the policy honour + the logger's own limits (`ToMaximumStringLength`)? +- **Testing:** does an assertion's message print content, and does it survive every type name + (nested, generic, nested in a generic)? +- **Analyzer Errors freeze** ([decision 34](../../../design/adr/0034-errors-fixed-within-major-version-warnings.md)): + an Error that misses a form misses it for the whole major version, and one that reports correct + code breaks every consumer's build on upgrade. Check the neighbours of each form a rule reaches: + `??` beside `?:`, `ConfigureAwait`/`WaitAsync` on a held task beside `await held`, a tuple or a + dictionary beside a collection, an extension block, a generic attribute, a static abstract or + inherited interface implementation. diff --git a/design/adr/0002-name-codometis-typekit.md b/design/adr/0002-name-codometis-typekit.md index 34a7403..d905b0d 100644 --- a/design/adr/0002-name-codometis-typekit.md +++ b/design/adr/0002-name-codometis-typekit.md @@ -6,4 +6,4 @@ Status: accepted Context: 2026-09-27. , replacing `CodoMetis.ValueObjects`, which was too - narrow. Package map in §1. + narrow. Package map in [Packages](../packages.md). diff --git a/design/adr/0005-option-result.md b/design/adr/0005-option-result.md index 244fb36..fc3358d 100644 --- a/design/adr/0005-option-result.md +++ b/design/adr/0005-option-result.md @@ -6,4 +6,4 @@ Status: accepted Context: 2026-09-27. : pure `Option`/`Result`, not a LanguageExt-style framework. - They ship in the base package. No `Try`/`Unit` ([§9](../option-result.md)). + They ship in the base package. No `Try`/`Unit` ([Option and Result](../option-result.md)). diff --git a/design/adr/0006-result-shape.md b/design/adr/0006-result-shape.md index 533ae03..b9d8dc0 100644 --- a/design/adr/0006-result-shape.md +++ b/design/adr/0006-result-shape.md @@ -6,4 +6,4 @@ Status: accepted Context: 2026-09-27. : `Result` (value first), `readonly record struct`, - private fields, non-positional ([§9](../option-result.md)). + private fields, non-positional ([Option and Result](../option-result.md)). diff --git a/design/adr/0007-analyzer-own-package.md b/design/adr/0007-analyzer-own-package.md index c7ba7ec..68926df 100644 --- a/design/adr/0007-analyzer-own-package.md +++ b/design/adr/0007-analyzer-own-package.md @@ -5,4 +5,4 @@ Status: accepted Context: 2026-09-27. -, reaching consumers through the base package ([§10](../analyzers.md)). +, reaching consumers through the base package ([Analyzers](../analyzers.md)). diff --git a/design/adr/0008-uninitialized-result-throws.md b/design/adr/0008-uninitialized-result-throws.md index 65b4ec6..feda2ad 100644 --- a/design/adr/0008-uninitialized-result-throws.md +++ b/design/adr/0008-uninitialized-result-throws.md @@ -5,4 +5,4 @@ Status: accepted Context: 2026-09-27. -instead of reporting `default(TError)` ([§9](../option-result.md)). +instead of reporting `default(TError)` ([Option and Result](../option-result.md)). diff --git a/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md b/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md index 0e60b4b..a9aa19e 100644 --- a/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md +++ b/design/adr/0009-ef-mapping-additive-irelationaltypemappingsourceplugin.md @@ -6,4 +6,4 @@ Status: accepted Context: 2026-09-27. , not a - replaced `IValueConverterSelector` or a pre-convention scan ([§4](../ef-core.md), spikes/EfMapping). + replaced `IValueConverterSelector` or a pre-convention scan ([EF Core](../ef-core.md), spikes/EfMapping). diff --git a/design/adr/0010-stored-json-storedjsonconverterfactory.md b/design/adr/0010-stored-json-storedjsonconverterfactory.md index 9aaffc7..15238ed 100644 --- a/design/adr/0010-stored-json-storedjsonconverterfactory.md +++ b/design/adr/0010-stored-json-storedjsonconverterfactory.md @@ -3,4 +3,4 @@ Date: 2026-09-27 Status: accepted -in the base package (2026-09-27, [§4](../ef-core.md)). +in the base package (2026-09-27, [EF Core](../ef-core.md)). diff --git a/design/adr/0011-openapi-schema-transformer.md b/design/adr/0011-openapi-schema-transformer.md index 186324a..86604ef 100644 --- a/design/adr/0011-openapi-schema-transformer.md +++ b/design/adr/0011-openapi-schema-transformer.md @@ -4,5 +4,5 @@ Date: 2026-09-27 Status: accepted that fills a value object's schema in from the -wrapped type's, as ASP.NET publishes it (2026-09-27, [§7](../openapi.md), spikes/OpenApiSchemas), not a document +wrapped type's, as ASP.NET publishes it (2026-09-27, [OpenAPI](../openapi.md), spikes/OpenApiSchemas), not a document transformer and not a format table. diff --git a/design/adr/0012-asp0020-documented-suppressed.md b/design/adr/0012-asp0020-documented-suppressed.md index 5b3c095..9448584 100644 --- a/design/adr/0012-asp0020-documented-suppressed.md +++ b/design/adr/0012-asp0020-documented-suppressed.md @@ -3,7 +3,7 @@ Date: 2026-09-27 Status: accepted -Context: 2026-09-27, §7. +Context: 2026-09-27, [OpenAPI](../openapi.md). : the package cannot suppress it, and a blanket suppression would hide the check for every other type. diff --git a/design/adr/0013-first-release-s-rule-set-cmtk0001.md b/design/adr/0013-first-release-s-rule-set-cmtk0001.md index 5d82894..91ef1d4 100644 --- a/design/adr/0013-first-release-s-rule-set-cmtk0001.md +++ b/design/adr/0013-first-release-s-rule-set-cmtk0001.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, [§10](../analyzers.md); CMTK0009 added the same +Context: 2026-09-28, [Analyzers](../analyzers.md); CMTK0009 added the same day, at Warning, for the calls that hand out a default. CMTK0004 ships at Error diff --git a/design/adr/0015-option-result-wire-types.md b/design/adr/0015-option-result-wire-types.md index 12a6788..9ee9017 100644 --- a/design/adr/0015-option-result-wire-types.md +++ b/design/adr/0015-option-result-wire-types.md @@ -3,7 +3,7 @@ Date: 2026-09-27 Status: accepted -Context: 2026-09-27, §9. +Context: 2026-09-27, [Option and Result](../option-result.md). : a `[JsonConverter]` on every exported struct of the base package throws `NotSupportedException` in both directions. Not a lossless diff --git a/design/adr/0016-native-aot-wherever-platform-allows.md b/design/adr/0016-native-aot-wherever-platform-allows.md index c569434..dfe1dd3 100644 --- a/design/adr/0016-native-aot-wherever-platform-allows.md +++ b/design/adr/0016-native-aot-wherever-platform-allows.md @@ -3,7 +3,7 @@ Date: 2026-09-27 Status: accepted -Context: 2026-09-27, §11. +Context: 2026-09-27, [Native AOT](../native-aot.md). : the run-time packages build with `IsAotCompatible`, the woven code is published with Native AOT in the smoke test, and a path that diff --git a/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md b/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md index cde3c0e..cecf344 100644 --- a/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md +++ b/design/adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md @@ -3,7 +3,7 @@ Date: 2026-09-27 Status: accepted -Context: 2026-09-27, [§3](../discovery.md), §11. +Context: 2026-09-27, [Discovery](../discovery.md), [Native AOT](../native-aot.md). , which the generators put on every value object, not `GetInterfaces()`. Measured under Native AOT: diff --git a/design/adr/0018-name-per-concept.md b/design/adr/0018-name-per-concept.md index 630fbda..dc08c35 100644 --- a/design/adr/0018-name-per-concept.md +++ b/design/adr/0018-name-per-concept.md @@ -16,7 +16,7 @@ Context: 2026-09-27, before the first release, when renaming still costs nothing else. - Delegate parameters: `onSome`/`onNone`, `onSuccess`/`onError`, `selector`, `predicate`, `action`, as LINQ names them. They were `fnSome`, `map`, `fn`, `check` and `defaultProvider`. -- Namespaces follow who uses a type ([§1](../packages.md)). The helpers only generated code calls moved to +- Namespaces follow who uses a type ([Packages](../packages.md)). The helpers only generated code calls moved to `CodoMetis.TypeKit.CompilerServices`, hidden from IntelliSense, and `Accepted` and `KnownGood` became one `GeneratedFactories` whose methods name the exception each entry point throws. - Internally: `ValueObjectAspect` (was the implementation aspect), `ValueObjectContractAspect`, diff --git a/design/adr/0019-concise-implicit.md b/design/adr/0019-concise-implicit.md index d16c5ef..4e3f13d 100644 --- a/design/adr/0019-concise-implicit.md +++ b/design/adr/0019-concise-implicit.md @@ -3,7 +3,7 @@ Date: 2026-09-27 Status: accepted -Context: 2026-09-27, §9. +Context: 2026-09-27, [Option and Result](../option-result.md). Markers where a target type exists, explicit type arguments where none does. diff --git a/design/adr/0023-value-object-key-over-integer-generated.md b/design/adr/0023-value-object-key-over-integer-generated.md index bb6d115..27da149 100644 --- a/design/adr/0023-value-object-key-over-integer-generated.md +++ b/design/adr/0023-value-object-key-over-integer-generated.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §4. +Context: 2026-09-28, [EF Core](../ef-core.md). Parity with the wrapped key where only the database can make one, and the application's `OrderId.New()` where it can. diff --git a/design/adr/0024-nested-value-objects-named-after-nesting.md b/design/adr/0024-nested-value-objects-named-after-nesting.md index 358adbf..37d1ee6 100644 --- a/design/adr/0024-nested-value-objects-named-after-nesting.md +++ b/design/adr/0024-nested-value-objects-named-after-nesting.md @@ -3,6 +3,6 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §7. +Context: 2026-09-28, [OpenAPI](../openapi.md). , as their companion classes are. diff --git a/design/adr/0026-value-object-s-json-wrapped-type.md b/design/adr/0026-value-object-s-json-wrapped-type.md index fd918c8..5f3b105 100644 --- a/design/adr/0026-value-object-s-json-wrapped-type.md +++ b/design/adr/0026-value-object-s-json-wrapped-type.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §5. +Context: 2026-09-28, [Aspects](../aspects.md). , under whatever options the application uses: swapping a primitive for a value object changes no wire format. diff --git a/design/adr/0027-no-refusal-quotes-input.md b/design/adr/0027-no-refusal-quotes-input.md index 764992d..9a2b178 100644 --- a/design/adr/0027-no-refusal-quotes-input.md +++ b/design/adr/0027-no-refusal-quotes-input.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §5. +Context: 2026-09-28, [Aspects](../aspects.md). , not even one the wrapped type raises: every generated `Parse`, type converter and JSON read replaces the wrapped type's exception with one that diff --git a/design/adr/0028-seams-declared-everything-else-refused.md b/design/adr/0028-seams-declared-everything-else-refused.md index 362998d..c36f9bc 100644 --- a/design/adr/0028-seams-declared-everything-else-refused.md +++ b/design/adr/0028-seams-declared-everything-else-refused.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §5. +Context: 2026-09-28, [Aspects](../aspects.md). : `TryFrom`, `FromKnownGood`, `Revalidate`, `CompareTo(TSelf)` and `ToString()` may be written by hand and are kept; any other diff --git a/design/adr/0029-only-result-terror-converts-bool.md b/design/adr/0029-only-result-terror-converts-bool.md index e44882d..da3371d 100644 --- a/design/adr/0029-only-result-terror-converts-bool.md +++ b/design/adr/0029-only-result-terror-converts-bool.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §9. +Context: 2026-09-28, [Option and Result](../option-result.md). On a valued result the conversion said whether the operation succeeded where a reader expects the value: `if (await diff --git a/design/adr/0030-asynchronous-callbacks-return-task.md b/design/adr/0030-asynchronous-callbacks-return-task.md index 3908a28..e9adf07 100644 --- a/design/adr/0030-asynchronous-callbacks-return-task.md +++ b/design/adr/0030-asynchronous-callbacks-return-task.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §9. +Context: 2026-09-28, [Option and Result](../option-result.md). `TapErrorAsync(Func)` sits beside `Action`, as `TapAsync` already did. It ships in 1.0 because adding it later diff --git a/design/adr/0031-no-extension-object-unconstrained-t.md b/design/adr/0031-no-extension-object-unconstrained-t.md index f09d2ad..fac087d 100644 --- a/design/adr/0031-no-extension-object-unconstrained-t.md +++ b/design/adr/0031-no-extension-object-unconstrained-t.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §9. +Context: 2026-09-28, [Option and Result](../option-result.md). : it is offered on every expression of every file that imports the namespace, and collides with anyone else's. diff --git a/design/adr/0032-value-object-holds-wrapped-value-alone.md b/design/adr/0032-value-object-holds-wrapped-value-alone.md index 9d9d939..bbec6a2 100644 --- a/design/adr/0032-value-object-holds-wrapped-value-alone.md +++ b/design/adr/0032-value-object-holds-wrapped-value-alone.md @@ -3,8 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, -§5. +Context: 2026-09-28, [Aspects](../aspects.md). Everything generated (JSON, parsing, the type converter, the EF column, comparison) carries the wrapped value, so other state (CMTK1012), a hand-written equality or an explicit diff --git a/design/adr/0033-analyzers-run-code-metalama-transformed.md b/design/adr/0033-analyzers-run-code-metalama-transformed.md index 6738066..b043b14 100644 --- a/design/adr/0033-analyzers-run-code-metalama-transformed.md +++ b/design/adr/0033-analyzers-run-code-metalama-transformed.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §10. +Context: 2026-09-28, [Analyzers](../analyzers.md). , through a `MetalamaTransformedCodeAnalyzer` item in the analyzer package's `buildTransitive` props. The diff --git a/design/adr/0034-errors-fixed-within-major-version-warnings.md b/design/adr/0034-errors-fixed-within-major-version-warnings.md index 5f43de3..c03e1d7 100644 --- a/design/adr/0034-errors-fixed-within-major-version-warnings.md +++ b/design/adr/0034-errors-fixed-within-major-version-warnings.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §10. +Context: 2026-09-28, [Analyzers](../analyzers.md). A new Error, or an Error that reports more, waits for a major version. A Warning or Info may report more forms in diff --git a/design/adr/0035-sqlite-s-autoincrement-application-s-line.md b/design/adr/0035-sqlite-s-autoincrement-application-s-line.md index 9d29c83..42bda72 100644 --- a/design/adr/0035-sqlite-s-autoincrement-application-s-line.md +++ b/design/adr/0035-sqlite-s-autoincrement-application-s-line.md @@ -3,7 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, §4. +Context: 2026-09-28, [EF Core](../ef-core.md). EF's SQLite provider decides by the CLR type, and the only other way in is an annotation typed by the diff --git a/design/adr/0036-nullable-value-object-admits-null-openapi.md b/design/adr/0036-nullable-value-object-admits-null-openapi.md index b4a821e..b8d6213 100644 --- a/design/adr/0036-nullable-value-object-admits-null-openapi.md +++ b/design/adr/0036-nullable-value-object-admits-null-openapi.md @@ -3,8 +3,7 @@ Date: 2026-09-28 Status: accepted -Context: 2026-09-28, -§7. +Context: 2026-09-28, [OpenAPI](../openapi.md). : as a property, as a container's element (`oneOf: [null, component]`, ASP.NET's own form), and inlined (null added to `type`, or `oneOf` around an inlined enum). diff --git a/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md index a2104ae..bdbe36f 100644 --- a/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md +++ b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md @@ -18,5 +18,5 @@ guards ship as string extensions and a fault in the base package, with no value what CMTK0002 refuses, a woven one in a package of its own costs a private generators reference, a boundary exemption, a baseline override and a smoke consumer for one line per field, and the type cannot be refined (CMTK1005) or told apart from another field of the same type (CMTK0008). -Public aspects are allowed in principle ([§5](../aspects.md)) and none exists. Clock-type analyzers and +Public aspects are allowed in principle ([Aspects](../aspects.md)) and none exists. Clock-type analyzers and decimal JSON converters stay out. diff --git a/design/adr/0052-cause-on-fault-joins-2-1.md b/design/adr/0052-cause-on-fault-joins-2-1.md index 4607d26..90eac24 100644 --- a/design/adr/0052-cause-on-fault-joins-2-1.md +++ b/design/adr/0052-cause-on-fault-joins-2-1.md @@ -10,7 +10,7 @@ exception was is lost on the way to it: `Result.Catch` makes the exception the w `MapError` that turns it into the fault an endpoint answers drops it, so a log of the refusal cannot say what failed underneath. A cause nested under an error is the one FluentResults capability a TypeKit user plausibly misses; success messages and a list of reasons stay outside what a `Result` -here is ([decision 5](0005-option-result.md), [§9](../option-result.md)). +here is ([decision 5](0005-option-result.md), [Option and Result](../option-result.md)). In 2.1, additive: an optional `Exception? Cause` on `Fault`, set through `Fault.Of(reason, detail, cause)`, kept beside the reason and the detail for the log and the fault diff --git a/design/analyzers.md b/design/analyzers.md index 291d501..bbd9841 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -19,7 +19,7 @@ code fixes under `analyzers/dotnet/cs`). `CodoMetis.TypeKit` takes a package dep layout. The consumer smoke test (phase 6) asserts CMTK0001 fires, directly and transitively. Attributes stay in `CodoMetis.TypeKit`. The analyzer resolves them and the interfaces with -`GetTypeByMetadataName` and compares symbols ([§3](discovery.md)), so it does not reference the base package. +`GetTypeByMetadataName` and compares symbols ([Discovery](discovery.md)), so it does not reference the base package. Inside the analyzer, rules that react to attributes are kept apart from value-object rules, so the split stays mechanical if a generic analyzer package is ever wanted. diff --git a/design/aspects.md b/design/aspects.md index 55bf1a5..497bbb8 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -1,6 +1,7 @@ # Aspects -- **Aspect-class count.** Ten classes. The Metalama Open Source edition (MIT) has no aspect-class +- **Aspect-class count.** Eleven classes: ten for value objects and `ClosedSetAspect`, whose design is + its own note ([Closed sets](closed-sets.md)). The Metalama Open Source edition (MIT) has no aspect-class limit (pricing page, 2026-09-26). The unlicensed CI build confirms it (phase 6). - **Entry points.** `ValueObjectAspect` introduces three private helpers on every value object, and they are the only way the JSON, parsing and type-converter aspects create an @@ -58,7 +59,7 @@ `$"{amount}"` was "1,5" in de-DE while `amount.ToString()` was "1.5". Display text in a culture passes it: `string.Create(culture, $"{amount}")`. - **The wrapped value's JSON contract** comes from `GeneratedJson.TypeInfo(options, builtIn)` - (decided 2026-09-27, [§11](native-aot.md)): the options' own contract when their resolver has one, so a host's + (decided 2026-09-27, [Native AOT](native-aot.md)): the options' own contract when their resolver has one, so a host's number handling and converters apply, and otherwise made in the serializer's order (a converter on the options, the type's `[JsonConverter]`, the built-in converter the aspect names by its property type). A source-generated context never sees what a value object wraps, and serializing through @@ -165,8 +166,8 @@ one's rules in its `Create`. Refused rather than made to work: a surface that follows the project layout is the silent failure this repo exists to prevent, and lifting a refusal later breaks nobody, while adding one after 1.0.0 would. - - The satellites keep their own refusal ([§7](openapi.md)): a value object declared by hand, with the - interfaces and `GeneratedValueObjectAttribute<,>` the satellites read ([§3](discovery.md)), can still wrap itself. + - The satellites keep their own refusal ([OpenAPI](openapi.md)): a value object declared by hand, with the + interfaces and `GeneratedValueObjectAttribute<,>` the satellites read ([Discovery](discovery.md)), can still wrap itself. - **Every JSON key goes through the wrapped type's own converter**, which knows the type's key format where it has one (an enum by name, a `Uri` as its text) and throws `NotSupportedException` where it has none. Writing the serialized value as the property name gave a `Uri` key quotes inside @@ -206,7 +207,7 @@ each aspect on 2026.1 as it lands, and use no 2027.0-only API. - **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`. Open point: ordering against a consumer's own aspects (fabric spike D). -- **Not in scope:** a `Try`/`Unit` type ([§9](option-result.md) non-goals; an exception enters a result through +- **Not in scope:** a `Try`/`Unit` type ([Option and Result](option-result.md) non-goals; an exception enters a result through `Result.Catch`, phase 13), clock-type analyzers, and decimal JSON converters (no aspect needs them, checked in phase 3). The text guards joined phase 13 on 2026-10-13, as string extensions in the base package. diff --git a/design/closed-sets.md b/design/closed-sets.md new file mode 100644 index 0000000..ddd8842 --- /dev/null +++ b/design/closed-sets.md @@ -0,0 +1,55 @@ +# Closed sets + +A closed set is a `sealed partial class` implementing `IClosedSet` (or `IFaultSet`), +whose members are its `public static readonly` fields of its own type. `ClosedSetAspect`, applied +by the same transitive fabric as the value-object aspects and last in `AspectOrder`, generates the +rest. Measured in [spikes/ClosedSets](../spikes/ClosedSets/README.md); shipped in 2.0.0 +([decision 42](adr/0042-2-0-0-next-release-bundles.md), [decision 44](adr/0044-2-0-0-net10-0-metalama.md)). +The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). + +## What is generated, and what must stay true + +- **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` + 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): + `with` would copy a member into an instance that is none. +- **Names come after the field initializers.** An initializer injected before the static constructor + (`InitializerKind.BeforeTypeConstructor`) assigns each member's name and builds `All`, so a member + needs no `nameof`, and the parts of a set split across files are all initialised when it runs. + The cost is a window: a static field initializer of the set itself runs before it, and anything + generated that it reads there is refused rather than answered from a half-built set. +- **`All` is a wrapper, never the array.** Cast to `IList` or `T[]`, the array was writable, and + a member replaced there was listed while `Parse` answered the original. +- **Ordinal unless asked** ([decision 51](adr/0051-closed-set-parses-ignoring-case-only.md)): JSON, + the EF column, binding and `IParsable` read the name as stored or sent; `ignoreCase: true` is an + overload the caller opts into. Two members whose names differ only in case are refused (CMTK1021), + since they are one name to that overload and, where the first letter differs, one `Match` + parameter. +- **No refusal quotes the input** ([decision 27](adr/0027-no-refusal-quotes-input.md)): an unknown + name is a `FormatException` or a `JsonException` naming the set, and its message links the + README's entry ([decision 37](adr/0037-refusal-s-message-landing-page.md)). + +## Where each satellite meets a set + +| Where | How | Held by | +|---|---|---| +| JSON | The name, as a value and as a dictionary key; no naming policy renames it, since a renamed name would not read back | `ClosedSetTests` | +| EF Core | A text column of the name through `ClosedSetConverter`, found by the type-mapping plugin through the discovery attribute, as a value object is ([EF Core](ef-core.md)); an unknown stored name throws when read | `ClosedSetMappingTests` | +| OpenAPI | A string enum of the names, as a component named after the nesting chain ([decision 24](adr/0024-nested-value-objects-named-after-nesting.md)), in every position a parameter or a property can take | `ClosedSetSchemaTests`, `ClosedSetBindingTests` | +| Fault responses | An `IFaultSet` member carries a `FaultKind` and an optional description, and `AddFaultResponses()` maps every member by kind ([decision 49](adr/0049-fault-responses-client-can-act.md)) | `FaultResponseTests` | +| Serilog | Logged as what it prints, never destructured into its constant data ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)) | the Serilog tests | + +## Diagnostics + +CMTK1013 to CMTK1021, all Errors, each reported on the declaration it names; the table with the +fixes is in the [Generators README](../src/CodoMetis.TypeKit.Generators/README.md). A set the aspect +cannot complete is never left half-generated. + +## Open + +- A stable external name for a member, apart from its C# identifier, waits for a spike + ([roadmap](roadmap.md)): parsing, JSON, EF, OpenAPI and the fault identifiers would change at once. diff --git a/design/discovery.md b/design/discovery.md index ca6fac8..d5afd72 100644 --- a/design/discovery.md +++ b/design/discovery.md @@ -8,8 +8,8 @@ passes in tests and breaks for the first consumer who renames something. | Analyzer, value-object rules | `CompilationStartAction` resolves `CodoMetis.TypeKit.ValueObjects.IValue`1` / `IValidatedValue`3` with `GetTypeByMetadataName` and compares `OriginalDefinition` with `SymbolEqualityComparer`. If `CodoMetis.TypeKit` is absent, nothing is registered | | Analyzer, `[RequireCustomInitialization]` | The attribute symbol is resolved the same way | | Analyzer, CMTK0002 | The compilation references an assembly whose name is exactly `CodoMetis.TypeKit.Generators`, ignoring case as assembly names do. That is the package's own id, which a consumer cannot rename, and a prefix or suffix match never counts. The assembly is not strong-named, so the name is the part of its identity to compare; the version must not be | -| EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` ([§4](ef-core.md)). There is no type scan and no assembly filter | -| OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata ([§7](openapi.md)). There is no referenced-assembly walk | +| EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` ([EF Core](ef-core.md)). There is no type scan and no assembly filter | +| OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata ([OpenAPI](openapi.md)). There is no referenced-assembly walk | Run-time code that holds only a `Type` reads the attribute rather than calling `GetInterfaces()` ([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). Its type arguments are constrained to `IValueObject` and diff --git a/design/ef-core.md b/design/ef-core.md index 49eb8b4..3f9faac 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -9,7 +9,7 @@ property can also name it explicitly. Its expressions call its own public static `ProviderValue` and `Materialize`: an expression tree cannot call a static abstract member directly (CS8927), and EF's compiled model writes the expressions out as C# in the application's - assembly, where an internal helper did not compile ([§11](native-aot.md)). `UseTypeKit()` composes a plain + assembly, where an internal helper did not compile ([Native AOT](native-aot.md)). `UseTypeKit()` composes a plain `ValueConverter` over the same expressions, the type the compiled model recreates, because a precompiled query casts a property's converter to its design-time type. - **No per-type comparer.** A `readonly record struct` already has value equality, and EF's default @@ -59,7 +59,7 @@ (`Properties()`) matches the property's CLR type; the README says to configure the value object (`Properties()`). Measured 2026-09-28 for precision, maximum length, column type and an enum conversion. -- **Stored JSON (decided 2026-09-27).** The generated JSON converter applies `Create` ([§5](aspects.md)), which +- **Stored JSON (decided 2026-09-27).** The generated JSON converter applies `Create` ([Aspects](aspects.md)), which is right for input and wrong for JSON the application stored itself (an event store, a document column): a rule added later would make old documents unreadable. `StoredJsonConverterFactory`, in the base package because it is a System.Text.Json concern, is the opt-in for such a store's diff --git a/design/native-aot.md b/design/native-aot.md index b1d20ac..faa24b1 100644 --- a/design/native-aot.md +++ b/design/native-aot.md @@ -20,11 +20,11 @@ host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativ as `{}` (both measured). That is ILCompiler's behaviour, not a contract, so the smoke test cannot catch a return to `GetInterfaces()` at run time; the analyzers' IL2070, an error in the packages' build, does. -- **JSON.** The generated converter's wrapped value goes through `GeneratedJson.TypeInfo` ([§5](aspects.md)), so a +- **JSON.** The generated converter's wrapped value goes through `GeneratedJson.TypeInfo` ([Aspects](aspects.md)), so a context lists the value objects and never what they wrap. A value object wrapping a type the serializer has no converter for (a class of the application's) still needs that type in the context, and the serializer's refusal names it. -- **OpenAPI.** The host's context lists what its value objects wrap ([§7](openapi.md)). +- **OpenAPI.** The host's context lists what its value objects wrap ([OpenAPI](openapi.md)). - **EF Core** is experimental under Native AOT and needs a compiled model and precompiled queries; its `DbContext` constructors require dynamic code. With the satellite, a value object maps, writes, queries by `.Value`, binds as a parameter and reads a stored value without validation, in the diff --git a/design/openapi.md b/design/openapi.md index 8a7c763..f1bae5f 100644 --- a/design/openapi.md +++ b/design/openapi.md @@ -25,7 +25,7 @@ position, and the change lands in the hoisted component. exactly what a parameter of the wrapped type publishes. - **A value object that reaches itself** through what it wraps is an `InvalidOperationException` naming the chain, not a stack overflow. -- **Source-generated JSON** ([§11](native-aot.md)): ASP.NET builds the wrapped type's schema from the host's JSON +- **Source-generated JSON** ([Native AOT](native-aot.md)): ASP.NET builds the wrapped type's schema from the host's JSON contract for it, and a source-generated context has none for a type the host never serializes. The transformer then refuses with an `InvalidOperationException` naming the value object and the `[JsonSerializable(typeof(Guid))]` to add, rather than the serializer's message about a Guid. A diff --git a/design/option-result.md b/design/option-result.md index a05f937..95666d6 100644 --- a/design/option-result.md +++ b/design/option-result.md @@ -84,7 +84,7 @@ is the value-less shape. Anything that turns this into a second LanguageExt is o gets the same message, and a dictionary key does too. That is what the serializer itself does for `System.Type`. It hands every type one converter typed `object`, which the serializer wraps: a converter per type needed `MakeGenericType`, and under Native AOT that failed for these structs - with the runtime's message instead of this one ([§11](native-aot.md)). + with the runtime's message instead of this one ([Native AOT](native-aot.md)). - A serialized shape says absent with `T?`; `ToOption()`/`OrNull()` convert at the boundary; a result is matched to a response or a document. An application that wants a wire format registers its own converter on its options, which takes precedence over the type's attribute, in a diff --git a/design/packages.md b/design/packages.md index 14f2541..ab506ad 100644 --- a/design/packages.md +++ b/design/packages.md @@ -2,13 +2,13 @@ | Package | Depends on | Metalama | Contents | |---|---|---|---| -| `CodoMetis.TypeKit` | Analyzers (flows, [§10](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([§9](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([§4](ef-core.md)); `RequireCustomInitializationAttribute`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([§3](discovery.md), [§5](aspects.md), [§11](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `IValueObjectVisitor<>`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource` | -| `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes ([§10](analyzers.md)). Nobody references it directly: it reaches every consumer through the base package | -| `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects, internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | +| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault` and `Fault.Of`; `Patch`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` | +| `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes ([Analyzers](analyzers.md)). Nobody references it directly: it reaches every consumer through the base package | +| `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects (the value-object family and `ClosedSetAspect`), internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | | `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` | -| `CodoMetis.TypeKit.AspNetCore` | TypeKit, Microsoft.AspNetCore.OpenApi | no | `AddTypeKit()`: one OpenAPI schema transformer ([§7](openapi.md)); `ToHttpResult()`, `AddFaultResponses` ([decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md)) | +| `CodoMetis.TypeKit.AspNetCore` | TypeKit, Microsoft.AspNetCore.OpenApi | no | `AddTypeKit()`: one OpenAPI schema transformer ([OpenAPI](openapi.md)); `ToHttpResult()`, `AddFaultResponses`, `FaultProblem`, `FaultResponseOptions`, `IFaultFeature` ([decision 38](adr/0038-result-becomes-response-explicit-tohttpresult-faults.md), [decision 49](adr/0049-fault-responses-client-can-act.md)) | | `CodoMetis.TypeKit.Serilog` | TypeKit, Serilog 4.0.0 | no | `Destructure.WithTypeKit()`: one destructuring policy that logs every value object as its `ToString()` ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)) | -| `CodoMetis.TypeKit.Testing` | TypeKit | no | `ShouldBeSome()`/`ShouldBeNone()`, `ShouldBeSuccess()`/`ShouldBeError()`: test assertions that hand back the content, with no test framework dependency ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)) | +| `CodoMetis.TypeKit.Testing` | TypeKit | no | `ShouldBeSome()`/`ShouldBeNone()`, `ShouldBeSuccess()`/`ShouldBeError()`, and the `Patch` assertions: test assertions that hand back the content, with no test framework dependency ([decision 40](adr/0040-codometis-typekit-testing-holds-assertions-hand.md)) | **The bare name is the cheapest package.** Someone who only wants `Option`/`Result` references `CodoMetis.TypeKit` and never receives Metalama by accident. Taking Metalama on is always an @@ -21,14 +21,17 @@ objects from a referenced domain assembly never needs Metalama itself. | Namespace | Holds | |---|---| | `CodoMetis.TypeKit` | `Option`, `Result`, `ResultState`, the markers, `NotWireTypeJsonConverterFactory`, `RequireCustomInitializationAttribute`, `GuidValueExtensions` (`New()` is call-site vocabulary, like `Option`) | +| `CodoMetis.TypeKit` (2.0.0) | `Fault`, `Patch`, `PatchState`, `PatchJsonConverterFactory`, `StringTextExtensions`, `TextFault`, the constraint attributes, `RequireParameterlessConstructorAttribute`, `ConstructorAccessibility` | +| `CodoMetis.TypeKit.ClosedSets` | `IClosedSet<>`, `IFault`, `IFaultSet<>`, `FaultKind`: what a user declares a closed set or a fault set with | | `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | | `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `IValueObjectVisitor`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | | `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`; the plugins, internal | -| `CodoMetis.TypeKit.AspNetCore` | the schema transformer, internal; `FaultProblem` | +| `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem`, `FaultResponseOptions`, `IFaultFeature` | +| `Microsoft.AspNetCore.Http` | `ToHttpResult()`, beside ASP.NET's own `Results` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | | `CodoMetis.TypeKit.Serilog` | the destructuring policy, internal | | `Serilog` | `WithTypeKit()`, beside Serilog's own configuration methods ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | | `CodoMetis.TypeKit.Testing` | the assertions and `TypeKitAssertionException` | | `Microsoft.EntityFrameworkCore` | `UseTypeKit()`, beside the providers' `UseNpgsql` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | -| `Microsoft.Extensions.DependencyInjection` | `AddEntityFrameworkTypeKit()` and `OpenApiOptions.AddTypeKit()`, beside `AddEntityFrameworkNpgsql` and `AddOpenApi` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `Microsoft.Extensions.DependencyInjection` | `AddEntityFrameworkTypeKit()`, `OpenApiOptions.AddTypeKit()` and `AddFaultResponses()`, beside `AddEntityFrameworkNpgsql` and `AddOpenApi` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | The internal aspects live in `CodoMetis.TypeKit.Generators`. Users never type that namespace. diff --git a/design/patch.md b/design/patch.md new file mode 100644 index 0000000..9d3ad9f --- /dev/null +++ b/design/patch.md @@ -0,0 +1,52 @@ +# Patch + +`Patch` is one field of a JSON Merge Patch (RFC 7396): absent, null or a value. It is in the base +package, is the one wire type there, and is hand-written, not generated. Measured in +[spikes/MergePatch](../spikes/MergePatch/README.md); shipped in 2.0.0 +([decision 42](adr/0042-2-0-0-next-release-bundles.md)). The consumer's guide is +[docs/merge-patches.md](../docs/merge-patches.md). + +## What must stay true + +- **`default` is Absent.** System.Text.Json calls no converter for a property it does not find, so + the state a missing property leaves is the struct's `default`. It is the one `default` in the + package that means something, so CMTK0001, CMTK0005 and CMTK0006 leave `Patch` alone + (`PatchIsDefaultableTests`). +- **A wire type beside `Option`, which is not one** ([decision 15](adr/0015-option-result-wire-types.md)): + an option says whether a value exists, a patch whether a request mentioned a field. `ToOption()` + crosses from one to the other, and nothing converts silently. +- **Reading never refuses a null.** Null is a state of the wire format whatever `T` is; whether a + clear is accepted is the application's rule, said in `Match`. A value object inside a patch is + read by its own converter, so its rule applies there as anywhere else. +- **Writing an absent patch is refused**, since a converter cannot leave a property out; + `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out instead. +- **`ToString()` never prints the value**, as for `Option` and `Result`. + +## The converter, and Native AOT + +A converter over an open `T` is the hard part. `MakeGenericType` plus `Activator` has no code under +Native AOT, a static field read by reflection is trimmed, and an open generic `[JsonConverter]` is +refused by the source generator (SYSLIB1220), all measured. So each closed `Patch`'s static +constructor registers its converter with `PatchJsonConverterFactory`, which runs the constructor +with `RuntimeHelpers.RunClassConstructor` for the type it is asked for. That call is the one +`[UnconditionalSuppressMessage]` (IL2059), its reason the spike's measurement and the smoke test's +`aot` consumer. The registry is a `ConcurrentDictionary`. + +Under a source-generated context the generator never sees `T` behind the converter: a primitive, a +string, a `Guid`, a date or a `Uri` gets the serializer's built-in contract, a value object its own +converter, and anything else must be listed in the context, where the serializer's own message +names what is missing. + +## Where each satellite meets a patch + +| Where | How | Held by | +|---|---|---| +| OpenAPI | The argument's schema, inlined, admitting null, never required, in 3.1 and 3.0 | `PatchSchemaTests` | +| Testing | Assertions per state that hand back the value | `PatchAssertionTests` | +| Serilog | Logs its state only | the Serilog tests | +| EF Core | Not mapped: a patch is the shape of a request, applied to an entity with its own types | — | + +## Not in scope + +A deep merge (`Patch
` replaces the address; nested patches are `Patch`), and +ASP.NET Core's JSON Patch (RFC 6902), which is a list of operations. diff --git a/design/plan.md b/design/plan.md index 1d490a3..49dbf50 100644 --- a/design/plan.md +++ b/design/plan.md @@ -12,6 +12,8 @@ you touch; the rest is reference. | [Discovery is by interface](discovery.md) | Why nothing is found by name, and how the analyzer and the satellites find a value object | with the area | | [EF Core](ef-core.md) | The mapping plugin, the convention, the translators, keys and collections | with the area | | [Aspects](aspects.md) | The aspects, the fabric, the seams, what the generators refuse | with the area | +| [Closed sets](closed-sets.md) | The closed-set aspect, what must stay true of a set, and where each satellite meets one | with the area | +| [Patch](patch.md) | `Patch`'s three states, its converter under Native AOT, and where each satellite meets one | with the area | | [Test strategy](test-strategy.md) | What is tested where, and the guards that hold the repository to its rules | with the area | | [OpenAPI](openapi.md) | The schema transformers and what was measured in the document | with the area | | [Option and Result](option-result.md) | Their shape, the non-goals, the combinators and the async forms | with the area | diff --git a/design/roadmap.md b/design/roadmap.md index 0ff3f3c..ee76641 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -23,6 +23,19 @@ item names the decision that scoped it. a nullable element; `Patch` under `Traverse` and `Map`. - CMTK1021 groups names ignoring case, which misses the one `Match` collision `ToLowerInvariant` makes and ordinal case folding does not (the Kelvin sign); it fails loudly, as LAMA0611 and CS0100. +- What the second outside review of 2026-10-13 rated can-wait. Analyzers: the CMTK0011 code fix + offered on a struct, which cannot have a non-public parameterless constructor (CS8958), and the + trivia it drops; CMTK0010 on an enum or a delegate under `[Concrete]` and on a type parameter whose + constraint already makes it a record, and its cost (`GetSymbolInfo` on every generic name, about + 0.5 s on a 6.7k-line file); CMTK0003 on an async expression-bodied member in the IDE, where + Metalama has not run. Generators: a C# keyword in a namespace or a member name fails with LAMA0041, + not a CMTK error; generated members carry no XML documentation, so IntelliSense shows nothing for + them. Run time: the Serilog policy ignores `ToMaximumStringLength`; an inlined nullable closed set + does not admit null in the document; an exception error prints its whole `ToString()` in a Testing + message, and there are no async assertions; `query.FirstOrNone(func)` binds to the sequence + overload and reads every row; a closed set's `.Name` in a query is not translated (it throws); the + closed-set JSON read allocates a string per token; `CatchAsync` over a `ValueTask` and + `TraverseAsync` on `Option` and `Patch`. ## 2.x, with a spike first diff --git a/design/test-strategy.md b/design/test-strategy.md index a003ad7..f86129c 100644 --- a/design/test-strategy.md +++ b/design/test-strategy.md @@ -6,12 +6,13 @@ 2. **Generated surface.** A small tool reflects over the woven probe types. For each value object it renders the public members, implemented interfaces and attributes as sorted text, which is committed and asserted. An aspect change that adds or loses a member then shows up in review - instead of shipping silently. Once 1.0.0 ships, package validation against the last release - guards the hand-written surface too. + instead of shipping silently. Package validation against the last release (1.5.1, for 2.0.0) + guards the hand-written surface, and an intended break is suppressed alone + ([decision 48](adr/0048-intended-break-suppressed-alone-baseline-stays.md)). 3. **Generated SQL.** `ToQueryString()` snapshots for `.Value`, `GetValue`, `ValueOrNull`, `StartsWith`, equality, and `Contains` over a list of ids. 4. **OpenAPI.** A probe host's emitted document, asserted per shape against a control that uses - the wrapped type directly, so the tests follow what ASP.NET publishes for that type ([§7](openapi.md)). + the wrapped type directly, so the tests follow what ASP.NET publishes for that type ([OpenAPI](openapi.md)). 5. **Performance.** Allocation tests hold the generated members to what the wrapped type allocates, and `benchmarks/` measures time against the wrapped types. The benchmarks are not a gate: they are run before a release and when a template changes. @@ -26,6 +27,6 @@ the test evaluates. A pre-release audit had found samples that did not compile (a bare fault as a `Result`, `quantity.Value;` as a statement, an ellipsis as a lambda body), CMTK0006 on the EF entity sample, and SQL the query does not produce; the consumer smoke test had found the first - once before ([§9](option-result.md)). Seeded: a README or sample edited alone, the bare fault back in both, the committed + once before ([Option and Result](option-result.md)). Seeded: a README or sample edited alone, the bare fault back in both, the committed READMEs, a changed SQL, JSON or string output, an opt-out on a compiled block or without a reason, an unlabelled fence, and either parser finding nothing each fail their test. diff --git a/spikes/FabricSpike/README.md b/spikes/FabricSpike/README.md index 883477a..358d935 100644 --- a/spikes/FabricSpike/README.md +++ b/spikes/FabricSpike/README.md @@ -47,7 +47,7 @@ dotnet run --project spikes/FabricSpike/Spike.App --no-build **A. The analyzer travels with the base package, not with the aspects.** Finding 8 is the fabric's price. With `[Inheritable]` on the interface, anyone who could name `IValue` also got the aspect. Now they get it only by referencing `.Generators`. So `CodoMetis.TypeKit` brings the -analyzer package along (plan [§10](../../design/analyzers.md)). When a type implements a marker and the compilation does not +analyzer package along ([Analyzers](../../design/analyzers.md)). When a type implements a marker and the compilation does not reference `CodoMetis.TypeKit.Generators`, it reports an error (CMTK0002). For the same reason `ForbiddenStructDefaultInitializationAnalyzer` (CMTK0001) travels the same way, since it guards the interfaces wherever they are visible. diff --git a/spikes/RangeValueObjects/README.md b/spikes/RangeValueObjects/README.md index 4b94767..7d4d485 100644 --- a/spikes/RangeValueObjects/README.md +++ b/spikes/RangeValueObjects/README.md @@ -67,13 +67,13 @@ OpenAPI transformer (`ValueRangesSchemaTransformer.cs`, copied verbatim) added b | 7 | JSON **without** `AddRangeConverters()`. On 8.0.0: `DateRange` writes `{"Length":181}`, and reading throws `NotSupportedException` (an abstract type). The value object writes the same `{"Length":181}`, reports "could not read the JSON value as DateRange", and an ASP.NET body holding it is a 400. Parity held, but the wire was wrong for both types, and it failed silently on writing. On 9.0.0: both write `"[2026-01-01,2026-06-30]"` and read it back, under default and web options. A refused range is "refused the JSON value (NotFinite)". With nothing registered, a valid body now binds (200), and a refused one is a 400 | ✅ 9.0.0 | | 8 | A dictionary key. On 8.0.0: neither `DateRange` nor the value object can be one, with or without the converters. On 9.0.0: both write `{"[2026-01-01,2026-06-30]":1}` under all four option sets. A `Dictionary` body binds (200), and a key `Create` refuses is a 400 | ✅ 9.0.0 | | 9 | EF model: in both registration orders (`UseNpgsql(n => n.UseValueRanges()).UseTypeKit()` and `UseTypeKit().UseNpgsql(…)`), `Period` and the nullable `Availability` map to `daterange`. Their mapping is ValueRanges' `ValueRangeTypeMapping` with TypeKit's converter composed onto it (`CompositeValueConverter`). `HasIndex(c => c.Period).HasMethod("gist")` creates a GiST index | ✅ | -| 10 | Round trip on PostgreSQL: the values read back equal to those written. A row the rule refuses, inserted by SQL (`'[2026-01-01,)'`), materializes without validation, as [§4](../../design/ef-core.md) intends, and `Revalidate()` reports `NotFinite` | ✅ | +| 10 | Round trip on PostgreSQL: the values read back equal to those written. A row the rule refuses, inserted by SQL (`'[2026-01-01,)'`), materializes without validation, as [EF Core](../../design/ef-core.md) intends, and `Revalidate()` reports `NotFinite` | ✅ | | 11 | Translation composes. See the table below; every query ran and returned the in-memory answer | ✅ | | 12 | ASP.NET Core minimal APIs, in a host with `AddRangeConverters()`: a valid body binds and echoes, and a body `Create` refuses is a 400. A query parameter binds through `TryParse` (`?period=[2026-01-01,2026-06-30]` → 200), and a refused one is a 400. No ASP0020 for a query parameter of a value object declared in the same project | ✅ | -| 13 | OpenAPI with `AddTypeKit()`: the value object's schema is `DateRange`'s, as [§7](../../design/openapi.md) requires. On 8.0.0 that is `{}` with the converters and an object with `length` without them. On 9.0.0 it is `{}` in every host, since ASP.NET cannot see through a converter. With ValueRanges' documented transformer, in either order with `AddTypeKit()`, both component schemas are `type: string` with the literal's `pattern` and description. TypeKit asks ASP.NET for the wrapped schema (`GetOrCreateSchemaAsync`), so the other transformer reaches the value object with no wiring | ✅ 9.0.0 + the copied transformer | +| 13 | OpenAPI with `AddTypeKit()`: the value object's schema is `DateRange`'s, as [OpenAPI](../../design/openapi.md) requires. On 8.0.0 that is `{}` with the converters and an object with `length` without them. On 9.0.0 it is `{}` in every host, since ASP.NET cannot see through a converter. With ValueRanges' documented transformer, in either order with `AddTypeKit()`, both component schemas are `type: string` with the literal's `pattern` and description. TypeKit asks ASP.NET for the wrapped schema (`GetOrCreateSchemaAsync`), so the other transformer reaches the value object with no wiring | ✅ 9.0.0 + the copied transformer | | 14 | A query parameter. ASP.NET describes a `DateRange` query parameter as `{"type":"string"}`: a `TryParse`-bound parameter is its own schema only for a primitive, an enum and eight listed types (`EndpointModelMetadata.GetDisplayType`, internal), and `string` for anything else. TypeKit replaced its `string` with the wrapped type's JSON schema regardless, which gave `{}` for the value object (and, with the transformer, a pattern a `DateRange` parameter does not get). Fixed in `ValueObjectSchemaTransformer`: the placeholder stays where ASP.NET keeps it for the wrapped type. After the fix the value object's query parameter is `{"type":"string"}` in all four hosts, as `DateRange`'s is | ✅ fixed in TypeKit | | 15 | Configuration binding. 9.0.0 gives `DateRange` a `TypeConverter` (`RangeTypeConverter`). `"[2026-01-01,2026-06-30]"` binds to both a `ContractPeriod?` and a `DateRange?` property. A range `Create` refuses and text that is no range both throw `InvalidOperationException` ("Failed to convert configuration value … to type 'ContractPeriod'"): the binder's message quotes the value, the value object's own does not | ✅ | -| 16 | A query array (`[FromQuery] ContractPeriod[]`): ASP.NET gives a `DateRange[]` query parameter no `items`, as it gives any converter-backed element none ([§7](../../design/openapi.md)). TypeKit's container branch puts the value object's component back, as for every value object (`{}` on 9.0.0, the literal's pattern with the copied transformer). Two values bind (200) in every host | ✅ | +| 16 | A query array (`[FromQuery] ContractPeriod[]`): ASP.NET gives a `DateRange[]` query parameter no `items`, as it gives any converter-backed element none ([OpenAPI](../../design/openapi.md)). TypeKit's container branch puts the value object's component back, as for every value object (`{}` on 9.0.0, the literal's pattern with the copied transformer). Two values bind (200) in every host | ✅ | Queries (finding 11), as `ToQueryString()` prints them: diff --git a/spikes/ResultCatch/README.md b/spikes/ResultCatch/README.md index 98f6b44..3f60403 100644 --- a/spikes/ResultCatch/README.md +++ b/spikes/ResultCatch/README.md @@ -1,7 +1,7 @@ # Spike: can an exception enter a `Result` through one factory, without a `Try` type? **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "`Result.Catch`" part of phase 13 -(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md). [§9](../../design/option-result.md) keeps `Try` and `Unit` out: +(2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md). [Option and Result](../../design/option-result.md) keeps `Try` and `Unit` out: a `Try` is `Result` under a second name, and `Result` is the value-less shape a `Unit` would stand in for. diff --git a/spikes/ResultHttp/README.md b/spikes/ResultHttp/README.md index e82e4de..d81995e 100644 --- a/spikes/ResultHttp/README.md +++ b/spikes/ResultHttp/README.md @@ -2,7 +2,7 @@ **Question.** Phase 12 puts "a `Result` as an HTTP response" into `CodoMetis.TypeKit.AspNetCore`: a success as its value, a fault as RFC 9457 problem details with a status the host maps, and the -endpoint's responses in the OpenAPI document. The domain must not reference ASP.NET, and [§9](../../design/option-result.md)'s refusal +endpoint's responses in the OpenAPI document. The domain must not reference ASP.NET, and [Option and Result](../../design/option-result.md)'s refusal to serialize a `Result` must stand. Which shape does ASP.NET Core 10 support for that, in minimal APIs and MVC, under Native AOT, and what happens when a host forgets to map a fault type? @@ -67,8 +67,8 @@ arguments from the result through a visitor, as `GeneratedValueObjectAttribute.A | 6 | Candidate 2 (metadata records only the fault type; the transformer reads the mapping): MVC and minimal APIs both documented (findings 2, 3). An operation transformer runs after ASP.NET hoisted schemas into components, so a schema from `GetOrCreateSchemaAsync` stays inline; `Document.AddComponent` plus an `OpenApiSchemaReference` gives the `$ref` ASP.NET's own metadata gives | ✅ | | 7 | Candidate 2 without the startup check, unmapped: only a fault response fails (`500`, the exception naming the fault type and `AddFaultResponses`), and the document fails; successes are served. Loud only once a fault occurs | ✅ | | 8 | A hosted service cannot do the startup check: hosted services start before the pipeline is built, and `EndpointDataSource` then lists 0 endpoints. An `IStartupFilter` sees all of them after `next(app)` (7 here, MVC included) and its exception fails `StartAsync` before the server listens, naming each endpoint and fault type | ✅ | -| 9 | Shape B under JIT: the filter turns the result into the same response, but the document describes the `200` as `ResultOfOrderAndOrderFault`, whose schema is the type's XML doc description, and lists no problem response; the startup check cannot see it, since nothing records its fault type. Without the filter, [§9](../../design/option-result.md)'s refusal answers `500` with its message, and the document still promises a `Result` | ✅ | -| 10 | Shape B under Native AOT: the host does not start. The Request Delegate Generator asks for `JsonTypeInfo` of `Result` when the endpoint is built. With the result added to the host's context it starts and the filter answers correctly; the unfiltered endpoint is [§9](../../design/option-result.md)'s refusal again | ✅ | +| 9 | Shape B under JIT: the filter turns the result into the same response, but the document describes the `200` as `ResultOfOrderAndOrderFault`, whose schema is the type's XML doc description, and lists no problem response; the startup check cannot see it, since nothing records its fault type. Without the filter, [Option and Result](../../design/option-result.md)'s refusal answers `500` with its message, and the document still promises a `Result` | ✅ | +| 10 | Shape B under Native AOT: the host does not start. The Request Delegate Generator asks for `JsonTypeInfo` of `Result` when the endpoint is built. With the result added to the host's context it starts and the filter answers correctly; the unfiltered endpoint is [Option and Result](../../design/option-result.md)'s refusal again | ✅ | | 11 | Shape A under Native AOT: no trim or AOT warning in the publish. Every fault answers `500` (`JsonTypeInfo` for `ProblemDetails` missing, the document too) until `AddProblemDetails()` is called, which puts ASP.NET's problem-details context into the host's resolver chain. The candidate calls it in `AddFaultResponses` | ✅ | | 12 | With it, Native AOT answers and documents exactly as JIT does (findings 1, 2), and the startup check refuses an unmapped fault type. `AddProblemDetails()` also adds a `traceId` to every problem, as ASP.NET does for its own | ✅ | @@ -78,7 +78,7 @@ arguments from the result through a visitor, as `GeneratedValueObjectAttribute.A the handler's return type is ASP.NET's own `Results<…>`, so the success's schema, MVC and the Request Delegate Generator all work without anything of ours (findings 1–3, 11). Shape B is not offered: it documents a `Result` (finding 9) and makes a Native AOT host put one in its context - (finding 10), the opposite of §9. + (finding 10), the opposite of [decision 15](../../design/adr/0015-option-result-wire-types.md). - **The mapping lives in the host**, registered per fault type, so the domain declares faults and never statuses. Metadata records only the fault type; the transformer and the startup filter read the mapping from the application's services (findings 4, 6, 8). diff --git a/spikes/SensitiveValues/README.md b/spikes/SensitiveValues/README.md index d67ba69..c957885 100644 --- a/spikes/SensitiveValues/README.md +++ b/spikes/SensitiveValues/README.md @@ -34,7 +34,7 @@ prototype Serilog destructuring policy. | 4 | Serilog: `{Masked}` prints the mask, but `{@Masked}` destructures the public properties, `MaskedEmail { Value: "ann@example.com" }`, and so does `{@Signup}` for every value object inside. Every value object, masked or not, is logged as `{ Value: … }` there | ✅ | | 5 | A destructuring policy that turns any value object (recognised by `GeneratedValueObjectAttribute<,>`) into a scalar of its `ToString()`: `{@Masked}` and `{@Signup}` print the mask, and an unmasked value object prints its value as a plain string instead of `{ Value: … }` | ✅ | | 6 | A `DataClassificationAttribute` on the value object's *type*: the logging generator reports LOGGEN035 ("has a sensitive field/property in its type") and generates nothing for the method, which then fails with CS8795 | ✅ | -| 7 | The debugger: no value object declares `[DebuggerDisplay]` or hides `Value`, so the debugger shows `ToString()`, the mask, and expands `Value`. [§9](../../design/option-result.md) already holds that the debugger is not a log | ✅ | +| 7 | The debugger: no value object declares `[DebuggerDisplay]` or hides `Value`, so the debugger shows `ToString()`, the mask, and expands `Value`. [Option and Result](../../design/option-result.md) already holds that the debugger is not a log | ✅ | ## Consequences diff --git a/spikes/TypeArgumentConstraints/README.md b/spikes/TypeArgumentConstraints/README.md index 8d866ed..55f09a0 100644 --- a/spikes/TypeArgumentConstraints/README.md +++ b/spikes/TypeArgumentConstraints/README.md @@ -2,7 +2,7 @@ **Status: run 2026-10-13, answered.** Proposed 2026-10-13, from the "Constraints a type parameter cannot declare" part of phase 13 (2.0.0) in the former plan's phase log (git, `design/plan.md` before 2026-10-13), [decision 43](../../design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md), and -the CMTK0010 row of §10. +the CMTK0010 row of [the analyzers note](../../design/analyzers.md). **Question.** C# has `where T : class`, `struct`, `new()` and a base type, but no `where T : abstract`, `sealed` or `record`. A generic base whose type argument must be an abstract record gets @@ -12,7 +12,7 @@ analyzer rule, CMTK0010, make the violation an error at every closed use, in eve without Metalama? An analyzer rather than an aspect, because an aspect on the parameter sees the declaration and -never the use, and the use site need not compile with Metalama ([§3](../../design/discovery.md): the base package is +never the use, and the use site need not compile with Metalama ([Discovery](../../design/discovery.md): the base package is Metalama-free, and discovery is by the attribute symbol). **Answer: yes.** Measured 2026-10-13 on .NET SDK 10.0.401 with the rule as `.Analyzers` ships it @@ -54,6 +54,6 @@ Each row is a claim the spike must settle. A seed that breaks the guard proves e | 8 | `[Record]` accepts a record class and a record struct; `[Abstract]` accepts an abstract class or record and refuses an interface; `[Sealed]` accepts a sealed class, a struct and a record struct; `[Concrete]` refuses an abstract type and an interface | measured: a sealed record, a struct and an enum pass `[Sealed]`, an interface fails it and `[Abstract]`; an abstract class fails `[Record]` and `[Concrete]`; a struct passes `[Concrete]` | | 9 | The four attributes compose: `[Abstract, Record]` requires both | measured: `[Abstract, Record]` reports each attribute broken, so an abstract class reports `is not a record` alone | | 10 | The open generic itself (`Base` inside its own body) is never reported | measured: `Decider` inside its own body and `typeof(Decider<,>)` report nothing; the library builds clean | -| 11 | A closed use in a Razor component is reported ([§10](../../design/analyzers.md), generated code with a mapped location) | measured: `Probe.razor(4,9): error CMTK0010` in a Razor class library | +| 11 | A closed use in a Razor component is reported ([Analyzers](../../design/analyzers.md), generated code with a mapped location) | measured: `Probe.razor(4,9): error CMTK0010` in a Razor class library | | 12 | `MakeGenericType` at run time is out of reach, stated in the README, and the attribute carries no run-time check | by design: no run-time check, the README says `MakeGenericType` is out of reach | | 13 | The attributes have `AttributeTargets.GenericParameter` only, so a misplaced one is CS0592 | measured in `TypeArgumentConstraintAnalyzerTests`: `[Abstract]` on a class is CS0592 | diff --git a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj index 316744c..d77fbfa 100644 --- a/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj +++ b/src/CodoMetis.TypeKit.Analyzers/CodoMetis.TypeKit.Analyzers.csproj @@ -2,7 +2,7 @@ netstandard2.0 diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index ab69d10..d45bd46 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -14,7 +14,7 @@ namespace CodoMetis.TypeKit.Generators; /// introduced: the seams it keeps, and what it declares that would collide with a generated member. /// /// -/// Every lookup enumerates a collection and compares names, never OfName (plan §5). +/// Every lookup enumerates a collection and compares names, never OfName (design/aspects.md). /// [CompileTime] internal static class ValueObjectDeclaration diff --git a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj index 633d7e7..498ca87 100644 --- a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj +++ b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj @@ -21,7 +21,7 @@ + + + + diff --git a/test/CodoMetis.TypeKit.Testing.Tests/FaultAssertionTests.cs b/test/CodoMetis.TypeKit.Testing.Tests/FaultAssertionTests.cs new file mode 100644 index 0000000..ee2e3b4 --- /dev/null +++ b/test/CodoMetis.TypeKit.Testing.Tests/FaultAssertionTests.cs @@ -0,0 +1,82 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace CodoMetis.TypeKit.Testing.Tests; + +/// +/// ShouldBeError(expected) on a Result<T, Fault<TFault>> takes a fault. A bare +/// reason converted to a fault without a detail, so ShouldBeError(OrderFault.NotFound) compiled +/// and failed on every fault that carried one, reporting two faults with the same reason. +/// +public sealed class FaultAssertionTests +{ + public enum OrderFault { NotFound, AlreadyShipped } + + private static Result> Gone => Result.Error(Fault.Of(OrderFault.NotFound, "Order 7 is gone.")); + + [Fact] + public void The_expected_fault_passes() => Gone.ShouldBeError(Fault.Of(OrderFault.NotFound, "Order 7 is gone.")); + + [Fact] + public void The_reason_is_asserted_on_the_fault_handed_back() => Gone.ShouldBeError().Reason.ShouldBe(OrderFault.NotFound); + + [Fact] + public void A_fault_with_another_detail_is_another_error() => + Should.Throw(() => Gone.ShouldBeError(Fault.Of(OrderFault.NotFound))) + .Message.ShouldStartWith("Expected the error Fault { Reason = NotFound, Detail = }, but"); + + [Theory] + [InlineData("Result> result) => result.ShouldBeError(OrderFault.NotFound);")] + [InlineData("Result> result) => result.ShouldBeError(OrderFault.NotFound);")] + public void A_bare_reason_is_not_the_expected_fault(string signatureEnd) + { + var problems = Compile($$""" + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.Testing; + + public enum OrderFault { NotFound } + + public static class Checks + { + public static void Assert({{signatureEnd}} + } + """); + + problems.ShouldNotBeEmpty(); + problems.ShouldAllBe(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void The_compilation_is_sound() => + Compile(""" + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.Testing; + + public enum OrderFault { NotFound } + + public static class Checks + { + public static void Assert(Result> result) => result.ShouldBeError(Fault.Of(OrderFault.NotFound)); + } + """).ShouldBeEmpty(); + + private static IReadOnlyList Compile(string source) => + [ + .. CSharpCompilation.Create( + "Consumer", + [CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp14))], + References.Value, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)) + .GetDiagnostics() + .Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning), + ]; + + private static readonly Lazy References = new(() => + [ + .. ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) + .Append(typeof(Option<>).Assembly.Location) + .Append(typeof(ResultAssertions).Assembly.Location) + .Distinct(StringComparer.Ordinal) + .Select(path => MetadataReference.CreateFromFile(path)), + ]); +} diff --git a/test/CodoMetis.TypeKit.Tests/FaultTests.cs b/test/CodoMetis.TypeKit.Tests/FaultTests.cs index 803e069..0215b82 100644 --- a/test/CodoMetis.TypeKit.Tests/FaultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/FaultTests.cs @@ -1,9 +1,10 @@ namespace CodoMetis.TypeKit.Tests; /// -/// : a reason and a sentence about one occurrence. A bare reason converts -/// to a fault without a detail, so comparing a fault with a reason compiled and was false whenever the -/// fault carried a detail; the mixed comparisons are compile errors that name Reason. +/// : a reason and a sentence about one occurrence. A bare reason converted +/// to a fault without a detail, so comparing a fault with a reason, directly or through a collection, an +/// equality comparer or an assertion, compiled and was false whenever the fault carried a detail. No +/// reason converts now, and the mixed comparisons are compile errors that name Reason. /// public sealed class FaultTests { @@ -11,6 +12,9 @@ private enum OrderFault { NotFound, NameTaken } private static string Consumer(string member) => $$""" + using System; + using System.Collections.Generic; + using System.Linq; using CodoMetis.TypeKit; public enum OrderFault { NotFound, NameTaken } @@ -35,8 +39,24 @@ public void Comparing_a_fault_with_a_bare_reason_is_a_compile_error_naming_Reaso compilation.Problems().ShouldContain("fault.Reason"); } + [Theory] + [InlineData("Fault Make() => OrderFault.NotFound;", "CS0029")] + [InlineData("bool Same(Fault? fault) => fault == OrderFault.NotFound;", "CS0019")] + [InlineData("bool Holds(List> faults) => faults.Contains(OrderFault.NotFound);", "CS1503")] + [InlineData("bool Holds(HashSet> faults) => faults.Contains(OrderFault.NotFound);", "CS1503")] + [InlineData("bool Holds(Fault[] faults) => faults.Contains(OrderFault.NotFound);", "CS1929")] + [InlineData("bool Same(Fault fault) => EqualityComparer>.Default.Equals(fault, OrderFault.NotFound);", "CS0176")] + public void A_bare_reason_is_not_a_fault(string member, string error) + { + var compilation = ConsumerCompilation.Of(Consumer(member)); + + compilation.ErrorIds().ShouldBe([error], compilation.Problems()); + } + [Theory] [InlineData("bool Same(Fault fault) => fault.Reason == OrderFault.NotFound;")] + [InlineData("Fault Make() => Fault.Of(OrderFault.NotFound);")] + [InlineData("bool Holds(List> faults) => faults.Exists(fault => fault.Reason == OrderFault.NotFound);")] [InlineData("bool Same(Fault fault, Fault other) => fault == other;")] [InlineData("bool Same(Fault fault) => fault == Fault.Of(OrderFault.NotFound, \"Order 7 is gone.\");")] public void Comparing_the_reason_or_two_faults_compiles(string member) => From 851e2f923d46d9424825abe0ff6fb3adb8a1d067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:17:50 +0200 Subject: [PATCH 056/147] fix: a closed set refuses All and a name lookup while it is initialized All is filled after every member exists, so a static field of the set that read it held null for the process's life, typed as never null; and Parse, TryParse and the JSON converter, read from a static initializer, handed out null for a member declared below the reading field, TryParse answering true with it. Each now throws an InvalidOperationException naming the set, linked to a new "Why does this throw?" entry. Co-Authored-By: Claude Opus 5.5 --- .../ClosedSetAspect.Templates.cs | 24 ++++--- .../CompilerServices/GeneratedClosedSets.cs | 11 ++++ src/CodoMetis.TypeKit/README.md | 16 +++++ src/CodoMetis.TypeKit/Refusals.cs | 3 + .../ClosedSetInitializationTests.cs | 66 +++++++++++++++++++ .../RefusalLinkTests.cs | 2 + 6 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs index 9abdf5d..e1ea079 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.Templates.cs @@ -61,10 +61,18 @@ public static void NamesInitializerTemplate() meta.ThisType._all = ExpressionFactory.Parse(ClosedSetTypes.AllExpression(args.Set, args.Members)).Value; } + /// + /// Refused while the set's static fields are still being initialized: All is filled after + /// every member exists, so a static field of the set that read it held null for the process's life. + /// [Template] public static dynamic? AllTemplate { - get => meta.ThisType._all; + get + { + var args = (ClosedSetArguments)meta.Tags.Source!; + return meta.ThisType._all ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); + } } [Template] @@ -75,7 +83,7 @@ public static bool TryParseTemplate([NotNullWhen(true)] string? name, [NotNullWh { if (name == candidate.Name) { - member = candidate.Value!; + member = candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); return true; } } @@ -98,7 +106,7 @@ public static bool TryParseIgnoreCaseTemplate([NotNullWhen(true)] string? name, { if (string.Equals(name, candidate.Name, comparison)) { - member = candidate.Value!; + member = candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); return true; } } @@ -114,7 +122,7 @@ public static bool TryParseIgnoreCaseTemplate([NotNullWhen(true)] string? name, var args = (ClosedSetArguments)meta.Tags.Source!; foreach (var candidate in args.Members) { - if (name == candidate.Name) return candidate.Value; + if (name == candidate.Name) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); } throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); @@ -128,7 +136,7 @@ public static bool TryParseIgnoreCaseTemplate([NotNullWhen(true)] string? name, var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; foreach (var candidate in args.Members) { - if (string.Equals(name, candidate.Name, comparison)) return candidate.Value; + if (string.Equals(name, candidate.Name, comparison)) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); } throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember(args.Set.Name); @@ -151,7 +159,7 @@ public static bool ParsableTryParseTemplate([NotNullWhen(true)] string? s, IForm { if (s == candidate.Name) { - result = candidate.Value!; + result = candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); return true; } } @@ -232,7 +240,7 @@ public TResult MatchTemplate(Func first) { foreach (var candidate in args.Members) { - if (name == candidate.Name) return candidate.Value; + if (name == candidate.Name) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); } } @@ -257,7 +265,7 @@ public void JsonWriteTemplate(Utf8JsonWriter writer, dynamic? value, JsonSeriali var name = reader.GetString(); foreach (var candidate in args.Members) { - if (name == candidate.Name) return candidate.Value; + if (name == candidate.Name) return candidate.Value ?? throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing(args.Set.Name); } throw global::CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson(args.Set.Name); diff --git a/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs index 230648d..b0bb4a3 100644 --- a/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs +++ b/src/CodoMetis.TypeKit/CompilerServices/GeneratedClosedSets.cs @@ -28,4 +28,15 @@ public static JsonException NoMemberInJson(string set) => /// The exception to throw. public static InvalidOperationException NotAMember(string set) => new($"{set}: this instance is not a member of the set, so it has no name.{Refusals.ClosedSetNotAMember}"); + + /// + /// The refusal of All, Parse, TryParse and the JSON converter while the set's + /// static fields are still being initialized: All is filled after every member exists, and a + /// member declared after the reading field is still null, so either would be read as null for the + /// rest of the process. + /// + /// The set's name. + /// The exception to throw. + public static InvalidOperationException StillInitializing(string set) => + new($"{set} is still being initialized: a static field initializer of the set read All, or a member by name, before every member existed.{Refusals.ClosedSetInitializing}"); } diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 3c356cf..f1d8b33 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -607,6 +607,22 @@ surfaces as a `TypeInitializationException`, and the set cannot be used for the instance, keep data a member needs in its own constructor's arguments, and read a name in a property or method rather than a static field initializer. +### A closed set read before it is initialized + +**Thrown:** `InvalidOperationException: OrderStatus is still being initialized: a static field +initializer of the set read All, or a member by name, before every member existed.` from `All`, +`Parse`, `TryParse` and the JSON converter, surfacing as a `TypeInitializationException`, after which +the set cannot be used for the rest of the process. + +**Why:** a static field initializer of the set runs while its members are still being created. `All` +is filled once every member exists, and a member declared below the reading field is still null, so +`public static readonly IReadOnlyList Snapshot = All;` held null, for a list typed as never +null, and `Parse` handed out null for a later member. + +**Instead:** read `All` or parse a name in a static property or method, which runs once the set is +initialized: `public static IEnumerable Active => All.Where(status => status != Cancelled);`. +In a field initializer, refer to a member by its field, `Open`, rather than by its name. + ### A task given to Catch **Thrown:** `ArgumentException: Catch was given a delegate whose value is a task…` from `Catch` when diff --git a/src/CodoMetis.TypeKit/Refusals.cs b/src/CodoMetis.TypeKit/Refusals.cs index 5fe5ebe..713b310 100644 --- a/src/CodoMetis.TypeKit/Refusals.cs +++ b/src/CodoMetis.TypeKit/Refusals.cs @@ -44,4 +44,7 @@ internal static class Refusals /// A closed set's instance that is not one of its members, asked for its name. public const string ClosedSetNotAMember = " See " + Readme + "an-instance-that-is-not-a-member-of-its-set"; + + /// A closed set's All, or a member by name, read by the set's own static initializers. + public const string ClosedSetInitializing = " See " + Readme + "a-closed-set-read-before-it-is-initialized"; } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs new file mode 100644 index 0000000..69fddb8 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// A set whose static field reads All: All is filled after every member exists. +public sealed partial class EarlyAllStatus : IClosedSet +{ + public static readonly EarlyAllStatus Open = new(); + public static readonly IReadOnlyList Snapshot = All; +} + +/// +/// A set whose static field parses the name of a member declared below it, still null there. A field of +/// the set's own type must be created with new (CMTK1019), so the parse is held in a list. +/// +public sealed partial class EarlyParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = [Parse("Closed")]; + public static readonly EarlyParseStatus Closed = new(); +} + +/// See , through TryParse. +public sealed partial class EarlyTryParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = TryParse("Closed", out var closed) ? [closed] : []; + public static readonly EarlyTryParseStatus Closed = new(); +} + +/// See , through the JSON converter. +public sealed partial class EarlyJsonStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = JsonSerializer.Deserialize>("""["Closed"]""")!; + public static readonly EarlyJsonStatus Closed = new(); +} + +/// +/// A closed set's static field initializers run while its members are still being created. All, +/// filled once every member exists, was null there, and a field that read it held null for the +/// process's life, typed as never null; a parse of a member declared below handed out null, and +/// TryParse answered true with it. Each refuses, naming the set and linking the README. +/// +public sealed class ClosedSetInitializationTests +{ + public static TheoryData Forms => ["All", "Parse", "TryParse", "JSON"]; + + private static readonly Dictionary Touch)> Touch = new() + { + ["All"] = ("EarlyAllStatus", () => EarlyAllStatus.Snapshot), + ["Parse"] = ("EarlyParseStatus", () => EarlyParseStatus.Defaults), + ["TryParse"] = ("EarlyTryParseStatus", () => EarlyTryParseStatus.Defaults), + ["JSON"] = ("EarlyJsonStatus", () => EarlyJsonStatus.Defaults), + }; + + [Theory] + [MemberData(nameof(Forms))] + public void Reading_the_set_in_its_own_static_initializer_is_refused(string form) + { + var (set, touch) = Touch[form]; + + var refusal = Should.Throw(touch).InnerException.ShouldBeOfType(); + + refusal.Message.ShouldStartWith($"{set} is still being initialized"); + refusal.Message.ShouldEndWith("#a-closed-set-read-before-it-is-initialized"); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index 49e8f30..20798c2 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -43,6 +43,7 @@ public sealed partial class RefusalLinkTests private const string NullToValueObject = "A null given to a value object"; private const string ClosedSetMember = "A closed set has no member of that name"; private const string ClosedSetNotAMember = "An instance that is not a member of its set"; + private const string ClosedSetInitializing = "A closed set read before it is initialized"; private const string TaskGivenToCatch = "A task given to Catch"; private enum Fault { TooLong } @@ -54,6 +55,7 @@ private enum Fault { TooLong } ["Closed set Parse of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMember("OrderStatus")), ["Closed set JSON of an unknown name"] = (ClosedSetMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NoMemberInJson("OrderStatus")), ["Closed set instance with no name"] = (ClosedSetNotAMember, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.NotAMember("OrderStatus")), + ["Closed set read while initializing"] = (ClosedSetInitializing, () => throw CodoMetis.TypeKit.CompilerServices.GeneratedClosedSets.StillInitializing("OrderStatus")), ["Option written as JSON"] = (InJson, () => JsonSerializer.Serialize(Option.Some(1))), ["Option read from JSON"] = (InJson, () => JsonSerializer.Deserialize>("1")), ["Option? written as a JSON null"] = (InJson, () => JsonSerializer.Serialize?>(null)), From 1ef30be41e1c85f16cb1c14c69522f7211bac0e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:21:15 +0200 Subject: [PATCH 057/147] fix: an abstract type with a value-object marker is CMTK1022 The fabric selected concrete types only, so an abstract type that declared a marker was given no aspect and no word: it compiled with no member generated, and a value held as it, of a sealed type derived from it, had no Value and was written as {}. The fabric now reports CMTK1022, an error, on every abstract class or record with a marker. An abstract closed set was already refused as unsealed (CMTK1014); a build-outcome case pins it. Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Analyzers/README.md | 2 +- .../AspectDiagnostics.cs | 3 ++ src/CodoMetis.TypeKit.Generators/README.md | 1 + .../ValueObjectFabric.cs | 6 +++ .../ValueObjectTypes.cs | 11 +++++ .../BuildOutcomeTests.cs | 45 ++++++++++++++++++- 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index b4cbf89..7251956 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -26,7 +26,7 @@ appears to: an instance that passed no factory, or a value object that was never | CMTK0012 | Error | A closed set's `Match` given an argument by position, which binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | The ids are public contract and never change meaning. The generators' own build errors, -CMTK1000 to CMTK1021, come from `CodoMetis.TypeKit.Generators` and are listed in its README. +CMTK1000 to CMTK1022, come from `CodoMetis.TypeKit.Generators` and are listed in its README. Every rule also runs in `.razor` and `.cshtml` files (see [Generated code](#generated-code-and-razor)). diff --git a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs index c38b3ec..ad78a85 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs @@ -83,4 +83,7 @@ internal static class AspectDiagnostics public static readonly DiagnosticDefinition<(INamedType Type, string Member, string Reason)> ClosedSetMemberNameTaken = new("CMTK1021", Severity.Error, "'{0}.{1}' {2}, so '{0}' is not generated: rename the member." + SeeBuildErrors); + + public static readonly DiagnosticDefinition AbstractValueObject = + new("CMTK1022", Severity.Error, "'{0}' is abstract, so it is not generated: an abstract type cannot be a value object. A value held as '{0}' would have no Value, and its JSON would be '{{}}'. Declare the marker on a sealed type." + SeeBuildErrors); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index ae2d20f..ec1bde4 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -355,6 +355,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | | CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose name differs from another member's only in case: one name to `TryParse(name, ignoreCase: true)`, and to `Match`'s parameters where the first letter is the difference. | +| CMTK1022 | An abstract type with a value-object marker: it would get no member, and a value held as it, of a type derived from it, would have no `Value` and be written as `{}`. Declare the marker on the sealed type. An abstract closed set is CMTK1014. | The analyzers that come with the base package add CMTK0001 to CMTK0012; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs index 6108c34..6ceba7f 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs @@ -1,5 +1,6 @@ using Metalama.Framework.Aspects; using Metalama.Framework.Code; +using Metalama.Framework.Diagnostics; using Metalama.Framework.Fabrics; namespace CodoMetis.TypeKit.Generators; @@ -39,6 +40,11 @@ public override void AmendProject(IProjectAmender amender) CompanionClass.NameOwner(type), ValueObjectTypes.WrappedValueObjectRefusal(type))); + // An abstract type gets no aspect, so it is refused here, where it is selected. + amender.SelectTypes(includeNestedTypes: true) + .Where(ValueObjectTypes.IsAbstractValueObject) + .ReportDiagnostic(type => AspectDiagnostics.AbstractValueObject.WithArguments(type)); + amender.SelectTypes(includeNestedTypes: true) .Where(ClosedSetTypes.IsClosedSet) .AddAspect(_ => new ClosedSetAspect()); diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs index 59ec21b..bbe5364 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs @@ -38,6 +38,17 @@ type.TypeKind is TypeKind.Struct or TypeKind.Class && !type.IsAbstract && Markers(type).Count > 0; + /// + /// An abstract class with a marker, which the fabric refuses (CMTK1022) rather than skip: it was + /// generated nothing and reported nothing, and a value held as it, of a sealed type derived from + /// it, had no Value and was written as {}. + /// + public static bool IsAbstractValueObject(INamedType type) => + type.TypeKind == TypeKind.Class + && type.IsAbstract + && !type.IsStatic + && Markers(type).Count > 0; + /// /// Why cannot be generated over what it wraps, or /// when that is not a value object. Called from the fabric only, since it diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 205d9b9..19fa453 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -118,6 +118,12 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1019", "SetWithUninitializedMember.B")] [InlineData("CMTK1019", "SetWithTrailingCall.B")] [InlineData("CMTK1020", "SetRecord")] + [InlineData("CMTK1014", "SetAbstract")] + [InlineData("CMTK1022", "AbstractRecord")] + [InlineData("CMTK1022", "AbstractClass")] + [InlineData("CMTK1022", "AbstractValidated")] + [InlineData("CMTK1022", "AbstractThroughInterface")] + [InlineData("CMTK1022", "AbstractHolder.AbstractNested")] [InlineData("CMTK1011", "SetWithName")] [InlineData("CMTK1011", "SetWithExplicitAll")] [InlineData("CMTK1011", "SetWithParsable")] @@ -342,6 +348,19 @@ public void A_seam_or_a_foreign_bound_is_not_refused(string type, string alsoNot public void A_hand_written_constructor_is_refused_by_name(string type, string constructor) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1009" && error.Message.StartsWith($"'{type}' declares {constructor},"), $"CMTK1009 on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + /// + /// An abstract type with a marker was given no aspect and no word: it compiled with no member + /// generated, and a value held as it, of a sealed type derived from it, had no Value and was + /// written as {}. It is refused where it is declared, once, as a value object it cannot be. + /// + [Fact] + public void An_abstract_value_object_is_refused_once_with_the_reason() => + consumer.Errors.Where(error => error.Message.Contains("'AbstractRecord'")) + .ShouldHaveSingleItem(consumer.Output) + .ShouldSatisfyAllConditions( + error => error.Id.ShouldBe("CMTK1022"), + error => error.Message.ShouldStartWith("'AbstractRecord' is abstract, so it is not generated: an abstract type cannot be a value object. A value held as 'AbstractRecord' would have no Value, and its JSON would be '{}'.")); + /// A static constructor makes no instance, so the value object is still generated. [Fact] public void A_static_constructor_is_allowed() => @@ -357,7 +376,7 @@ 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", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); + .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK0012", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CMTK1022", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); /// /// CS0535 is allowed above only for a refused closed set, whose interface members the generators @@ -384,7 +403,7 @@ public void Every_build_error_links_to_the_Build_errors_table() var errors = consumer.Errors.Where(error => error.Id.StartsWith("CMTK1", StringComparison.Ordinal)).ToList(); - errors.Select(error => error.Id).Distinct().Count().ShouldBe(22, $"Not every CMTK1000-CMTK1021 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); + errors.Select(error => error.Id).Distinct().Count().ShouldBe(23, $"Not every CMTK1000-CMTK1022 error was observed, so the check below is incomplete:{Environment.NewLine}{consumer.Output}"); errors.Where(error => !error.Message.EndsWith($". See {link}", StringComparison.Ordinal)) .Select(error => $"{error.Id}: {error.Message}") .ShouldBeEmpty("These errors do not end with the link to the Build errors table."); @@ -480,6 +499,9 @@ public sealed partial class SetWithoutMembers : IClosedSet { public sealed partial record SetRecord : IClosedSet { public static readonly SetRecord A = new(); } + // Abstract, so a closed set it is not: refused as unsealed, never skipped. + public abstract partial class SetAbstract : IClosedSet { } + public sealed partial class SetWithName : IClosedSet { public static readonly SetWithName A = new(); public string Name => "A"; } public sealed partial class SetWithExplicitAll : IClosedSet { public static readonly SetWithExplicitAll A = new(); static System.Collections.Generic.IReadOnlyList IClosedSet.All => [A]; } @@ -558,6 +580,25 @@ public partial record NotSealed : IValue; public sealed partial record DerivesFromAValueObject : NotSealed; + // Abstract: given no aspect and no word, while a value held as one, of a sealed type + // derived from it, had no Value and was written as {}. + public abstract partial record AbstractRecord : IValue; + + public sealed partial record DerivesFromAbstract : AbstractRecord; + + public abstract class AbstractClass : IValue { } + + public abstract partial record AbstractValidated : IValidatedValue + { + public static Result Create(int value) => Result.Error(Fault.Refused); + } + + public interface IAbstractIdentifier : IValue { } + + public abstract partial record AbstractThroughInterface : IAbstractIdentifier; + + public static class AbstractHolder { public abstract partial record AbstractNested : IValue; } + // A value object that reaches itself through what it wraps has no finite form: its JSON // converter serializes the wrapped value through the options, which is its own converter // again. As a record class it compiled without a word; as a struct it failed inside the From 50cd09be962a015b8dac156134b797449445efbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:24:01 +0200 Subject: [PATCH 058/147] fix: Patch.IfValue is Tap, and Tap and IfPresent return the patch Option.Tap and Result.Tap name a side effect on the value and return their receiver (decision 18, one name per concept); the patch's IfValue named the same idea and returned nothing. It is Tap now, returning the patch, and IfPresent, the form a clear reaches, keeps its name and returns the patch too, so the two side-effect members have one shape. CMTK0003 reports neither as a statement, since a patch is no outcome. Co-Authored-By: Claude Opus 5.5 --- docs/merge-patches.md | 14 +++---- src/CodoMetis.TypeKit/Patch.cs | 28 ++++++++------ src/CodoMetis.TypeKit/README.md | 2 +- .../IgnoredOutcomeAnalyzerTests.cs | 21 ++++++++++ .../Base/PatchSamples.cs | 2 +- .../Docs/MergePatchesSamples.cs | 2 +- .../NullDelegateTests.cs | 2 +- test/CodoMetis.TypeKit.Tests/PatchTests.cs | 38 +++++++++++++++++-- 8 files changed, 83 insertions(+), 26 deletions(-) diff --git a/docs/merge-patches.md b/docs/merge-patches.md index a1c5b1d..4295faa 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -30,7 +30,7 @@ PatchState phone = patch.Phone.State; // Null, a clear PatchState floor = patch.Floor.State; // Value ``` -| In the document | `State` | `Match` calls | `TryGetValue` | `IfValue` runs | `IfPresent` runs | `ToOption()` | +| In the document | `State` | `Match` calls | `TryGetValue` | `Tap` runs | `IfPresent` runs | `ToOption()` | |---|---|---|---|---|---|---| | not mentioned | `Absent` | `onAbsent` | false | no | no | `None` | | `null` | `Null` | `onNull` | false | no | with `null` | `None` | @@ -62,7 +62,7 @@ public static class ContactPatching { if (patch.Email.State == PatchState.Null) return ContactFault.EmailRequired; // a required field refuses a clear - patch.Email.IfValue(email => contact.Email = email); + patch.Email.Tap(email => contact.Email = email); patch.Phone.IfPresent(phone => contact.Phone = phone); // an optional one: null clears it patch.Floor.IfPresent(floor => contact.Floor = floor); // a struct's clear arrives as int?, null, never as 0 return Result.Success(); @@ -81,11 +81,11 @@ int? floor = contact.Floor; // 3 Result refused = contact.Apply(clearEmail); // Error: EmailRequired ``` -`IfValue` runs only for a value, for a target a clear cannot reach; `IfPresent` runs for a value and -for a clear; `Match` takes all three, for a rule that has to say what each means, and `ToOption()` -is for a caller to whom a clear and an absence are the same. Refusing a clear of a required field, -as `Apply` does, says so to the client; ignoring it with `IfValue` alone would accept the request and -do less than it asked. +`Tap` runs only for a value, for a target a clear cannot reach, as `Option`'s `Tap` does; `IfPresent` +runs for a value and for a clear. Both return the patch unchanged. `Match` takes all three, for a rule +that has to say what each means, and `ToOption()` is for a caller to whom a clear and an absence are +the same. Refusing a clear of a required field, as `Apply` does, says so to the client; ignoring it +with `Tap` alone would accept the request and do less than it asked. ## A value object inside a patch diff --git a/src/CodoMetis.TypeKit/Patch.cs b/src/CodoMetis.TypeKit/Patch.cs index 63af587..1cccd6c 100644 --- a/src/CodoMetis.TypeKit/Patch.cs +++ b/src/CodoMetis.TypeKit/Patch.cs @@ -52,10 +52,11 @@ public enum PatchState /// serializer refuses with its own message naming the context. /// /// -/// Applied without reflection: patch.Name.IfValue(name => order.Name = name) for a field a -/// clear cannot reach, patch.Nickname.IfPresent(nickname => order.Nickname = nickname) for one -/// it can, which receives a T?: null for a clear, over a struct as over a class. ToString() -/// never prints the value. +/// Applied without reflection: patch.Name.Tap(name => order.Name = name) for a field a +/// clear cannot reach, as runs on a value, and +/// patch.Nickname.IfPresent(nickname => order.Nickname = nickname) for one it can, which +/// receives a T?: null for a clear, over a struct as over a class. Both return the patch +/// unchanged. ToString() never prints the value. /// /// /// The type of the value. @@ -113,12 +114,15 @@ public bool TryGetValue([MaybeNullWhen(false)] out T value) /// Runs with the value when the field carried one, for a target a clear cannot reach. /// What to do with the value. + /// This patch, unchanged. /// is null. - public void IfValue(Action action) + public Patch Tap(Action action) { ArgumentNullException.ThrowIfNull(action); if (_state == PatchState.Value) action(_value!); + + return this; } /// @@ -170,14 +174,15 @@ public static class Patch { /// Runs with the value, or with null when the field was null, for a target a clear can reach. An absent field runs nothing. /// What to do with the value or the clear. + /// The patch, unchanged. /// is null. - public void IfPresent(Action action) + public Patch IfPresent(Action action) { ArgumentNullException.ThrowIfNull(action); - if (patch.State == PatchState.Absent) return; + if (patch.State != PatchState.Absent) action(patch.TryGetValue(out var value) ? value : null); - action(patch.TryGetValue(out var value) ? value : null); + return patch; } } @@ -191,14 +196,15 @@ public void IfPresent(Action action) /// uninitialized value object. An absent field runs nothing. /// /// What to do with the value or the clear. + /// The patch, unchanged. /// is null. - public void IfPresent(Action action) + public Patch IfPresent(Action action) { ArgumentNullException.ThrowIfNull(action); - if (patch.State == PatchState.Absent) return; + if (patch.State != PatchState.Absent) action(patch.TryGetValue(out var value) ? value : null); - action(patch.TryGetValue(out var value) ? value : null); + return patch; } } diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index f1d8b33..adc2e03 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -400,7 +400,7 @@ var quantity = patch.Quantity; // Value: 2 analyzers leave it alone. Applying one needs no reflection: ```csharp -patch.Name.IfValue(name => order.Name = name); // a clear cannot reach a required field +patch.Name.Tap(name => order.Name = name); // a clear cannot reach a required field patch.Nickname.IfPresent(nickname => order.Nickname = nickname); // null clears an optional one order.Quantity = patch.Quantity.Match( quantity => quantity, diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index e8828a8..f614748 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -141,6 +141,27 @@ public static async Task Run(Service service, Service? maybe) Cmtk0003(8, "Result", "LoadAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A patch is no outcome: its Tap and IfPresent return it, as Option's Tap + /// returns the option, and a statement that drops it drops nothing anyone has to decide on. + /// + [Fact] + public Task A_patch_s_Tap_and_IfPresent_as_statements_are_silent() => + Test( + """ + public static class Caller + { + public static void Run(Patch quantity, Patch nickname, List seen) + { + quantity.Tap(seen.Add); + quantity.IfPresent(value => seen.Add(value ?? 0)); + nickname.IfPresent(value => seen.Add(value?.Length ?? 0)); + Patch.Value(3).Tap(seen.Add); + } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + /// /// Tap, TapAsync, TapError and TapErrorAsync return their receiver: /// dropping it loses nothing only when the receiver is still held. diff --git a/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs index 4d050b5..df8a097 100644 --- a/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs @@ -19,7 +19,7 @@ public static class Applying public static void Apply(Order order, OrderPatch patch) { // sample: CodoMetis.TypeKit/patch-apply - patch.Name.IfValue(name => order.Name = name); // a clear cannot reach a required field + patch.Name.Tap(name => order.Name = name); // a clear cannot reach a required field patch.Nickname.IfPresent(nickname => order.Nickname = nickname); // null clears an optional one order.Quantity = patch.Quantity.Match( quantity => quantity, diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs index 237f1be..acbcf1b 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs @@ -41,7 +41,7 @@ public static Result Apply(this Contact contact, ContactPatch patc { if (patch.Email.State == PatchState.Null) return ContactFault.EmailRequired; // a required field refuses a clear - patch.Email.IfValue(email => contact.Email = email); + patch.Email.Tap(email => contact.Email = email); patch.Phone.IfPresent(phone => contact.Phone = phone); // an optional one: null clears it patch.Floor.IfPresent(floor => contact.Floor = floor); // a struct's clear arrives as int?, null, never as 0 return Result.Success(); diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index 2214843..b2babd5 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -97,7 +97,7 @@ public sealed class NullDelegateTests [() => Patch.Value(1).Match(x => x, Null>(), () => 0), () => Patch.Null().Match(x => x, Null>(), () => 0), () => Patch.Absent().Match(x => x, Null>(), () => 0)], ["Patch.Match(Func, Func, Func): onAbsent"] = [() => Patch.Value(1).Match(x => x, () => 0, Null>()), () => Patch.Null().Match(x => x, () => 0, Null>()), () => Patch.Absent().Match(x => x, () => 0, Null>())], - ["Patch.IfValue(Action): action"] = [() => { Patch.Value(1).IfValue(Null>()); return null; }, () => { Patch.Null().IfValue(Null>()); return null; }, () => { Patch.Absent().IfValue(Null>()); return null; }], + ["Patch.Tap(Action): action"] = [() => { Patch.Value(1).Tap(Null>()); return null; }, () => { Patch.Null().Tap(Null>()); return null; }, () => { Patch.Absent().Tap(Null>()); return null; }], ["Patch.IfPresent(Patch, Action): action"] = [() => { Patch.Value("a").IfPresent(Null>()); return null; }, () => { Patch.Null().IfPresent(Null>()); return null; }, () => { Patch.Absent().IfPresent(Null>()); return null; }], ["Patch.Map(Func): selector"] = [() => Patch.Value(1).Map(Null>()), () => Patch.Null().Map(Null>()), () => Patch.Absent().Map(Null>())], ["Result.Traverse(Patch, Func>): selector"] = diff --git a/test/CodoMetis.TypeKit.Tests/PatchTests.cs b/test/CodoMetis.TypeKit.Tests/PatchTests.cs index c17d2f0..d458b94 100644 --- a/test/CodoMetis.TypeKit.Tests/PatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/PatchTests.cs @@ -33,7 +33,7 @@ public void Default_is_absent_and_the_states_are_told_apart() } [Fact] - public void Match_TryGetValue_IfValue_and_IfPresent_follow_the_state() + public void Match_TryGetValue_Tap_and_IfPresent_follow_the_state() { var seen = new List(); @@ -45,9 +45,9 @@ public void Match_TryGetValue_IfValue_and_IfPresent_follow_the_state() value.ShouldBe("Ada"); Patch.Null().TryGetValue(out _).ShouldBeFalse(); - Patch.Value("Ada").IfValue(seen.Add); - Patch.Null().IfValue(seen.Add); - Patch.Absent().IfValue(seen.Add); + Patch.Value("Ada").Tap(seen.Add); + Patch.Null().Tap(seen.Add); + Patch.Absent().Tap(seen.Add); seen.ShouldBe(["Ada"]); seen.Clear(); @@ -57,6 +57,36 @@ public void Match_TryGetValue_IfValue_and_IfPresent_follow_the_state() seen.ShouldBe(["Ada", ""]); } + /// + /// One name per concept (decision 18): a side effect on the value is Tap, as on Option + /// and Result, and returns the patch as theirs return the receiver. It was IfValue, + /// returning nothing, and IfPresent, the form a clear reaches, returned nothing beside it. + /// + [Fact] + public void The_side_effects_are_named_and_shaped_as_Option_s_Tap() + { + typeof(Patch).GetMethods().Select(method => method.Name).ShouldNotContain("IfValue"); + typeof(Patch).GetMethod(nameof(Patch.Tap))!.ReturnType.ShouldBe(typeof(Patch)); + typeof(Option).GetMethod(nameof(Option.Tap))!.ReturnType.ShouldBe(typeof(Option)); + typeof(Patch).GetMethods() + .Where(method => method.Name == "IfPresent") + .ShouldAllBe(method => method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Patch<>)); + typeof(Patch).GetMethods().Count(method => method.Name == "IfPresent").ShouldBe(2); + } + + [Fact] + public void Tap_and_IfPresent_return_the_patch_unchanged() + { + foreach (var patch in new[] { Patch.Value("Ada"), Patch.Null(), Patch.Absent() }) + { + patch.Tap(_ => { }).ShouldBe(patch); + patch.IfPresent(_ => { }).ShouldBe(patch); + } + + Patch.Value(3).IfPresent(_ => { }).ShouldBe(Patch.Value(3)); + Patch.Null().IfPresent(_ => { }).ShouldBe(Patch.Null()); + } + /// /// A clear over a struct reaches the action as null. It reached it as default(T): a quantity /// cleared to zero, or a struct value object handed over uninitialized. From e779dd3b65547ed8a7680a2f332c75494ec966d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:26:17 +0200 Subject: [PATCH 059/147] fix: every async combinator refuses a delegate that returns no task TraverseAsync, and Option's and both Results' MapAsync, BindAsync, TapAsync, TapErrorAsync and RecoverAsync, awaited what the delegate returned, so a null task was a NullReferenceException at the await, naming nothing. Each now throws the ArgumentNullException CatchAsync throws, naming the combinator and the delegate's parameter and linking the README's "A null in an Option or a Result". The Task continuations reach the same check through the instance members. Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit/Option.cs | 6 +- src/CodoMetis.TypeKit/README.md | 3 +- src/CodoMetis.TypeKit/Result.cs | 10 ++-- src/CodoMetis.TypeKit/ResultExtensions.cs | 12 ++-- src/CodoMetis.TypeKit/ResultT.cs | 12 ++-- src/CodoMetis.TypeKit/ThrowHelper.cs | 16 ++++++ test/CodoMetis.TypeKit.Tests/NullTaskTests.cs | 55 +++++++++++++++++++ 7 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Tests/NullTaskTests.cs diff --git a/src/CodoMetis.TypeKit/Option.cs b/src/CodoMetis.TypeKit/Option.cs index 3c836b3..96ab58e 100644 --- a/src/CodoMetis.TypeKit/Option.cs +++ b/src/CodoMetis.TypeKit/Option.cs @@ -117,7 +117,7 @@ public async Task> MapAsync(Func> sele { ArgumentNullException.ThrowIfNull(selector); - return _hasValue ? Option.Some(await selector(_value!).ConfigureAwait(false)) : Option.None(); + return _hasValue ? Option.Some(await ThrowHelper.Awaitable(selector(_value!), nameof(MapAsync), nameof(selector)).ConfigureAwait(false)) : Option.None(); } /// Chains an asynchronous operation that may itself produce no value. @@ -128,7 +128,7 @@ public async Task> BindAsync(Func(); + return _hasValue ? await ThrowHelper.Awaitable(selector(_value!), nameof(BindAsync), nameof(selector)).ConfigureAwait(false) : Option.None(); } /// Runs an asynchronous side effect on the value, if there is one. @@ -139,7 +139,7 @@ public async Task> TapAsync(Func action) ArgumentNullException.ThrowIfNull(action); if (_hasValue) - await action(_value!).ConfigureAwait(false); + await ThrowHelper.Awaitable(action(_value!), nameof(TapAsync), nameof(action)).ConfigureAwait(false); return this; } diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index adc2e03..7b4bd22 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -510,7 +510,8 @@ there too. over a null element, or for a null error given to `ToResult`, `Ensure` or `FirstOrError`. A `Patch.Map` whose selector returns null throws the same: a clear is the Null state, never a value. So does `CatchAsync` for a delegate that returns no task: a null cannot be awaited, and it is not an -exception the call threw. +exception the call threw. Every other asynchronous combinator, `MapAsync`, `BindAsync`, `TapAsync`, +`TapErrorAsync`, `RecoverAsync` and `TraverseAsync`, refuses such a delegate the same way. **Why:** an option that reports a value always has one, and a result's value and error are never null. `notnull` is not checked at run time, so the factories check it. diff --git a/src/CodoMetis.TypeKit/Result.cs b/src/CodoMetis.TypeKit/Result.cs index 1a0f795..b92fd68 100644 --- a/src/CodoMetis.TypeKit/Result.cs +++ b/src/CodoMetis.TypeKit/Result.cs @@ -205,7 +205,7 @@ public async Task> TapAsync(Func action) ArgumentNullException.ThrowIfNull(action); if (Succeeded) - await action().ConfigureAwait(false); + await ThrowHelper.Awaitable(action(), nameof(TapAsync), nameof(action)).ConfigureAwait(false); return this; } @@ -219,7 +219,7 @@ public async Task> TapErrorAsync(Func action) ArgumentNullException.ThrowIfNull(action); if (!Succeeded) - await action(_error!).ConfigureAwait(false); + await ThrowHelper.Awaitable(action(_error!), nameof(TapErrorAsync), nameof(action)).ConfigureAwait(false); return this; } @@ -234,7 +234,7 @@ public async Task> MapAsync(Func> ArgumentNullException.ThrowIfNull(selector); return Succeeded - ? Result.Success(await selector().ConfigureAwait(false)) + ? Result.Success(await ThrowHelper.Awaitable(selector(), nameof(MapAsync), nameof(selector)).ConfigureAwait(false)) : Result.Error(_error!); } @@ -246,7 +246,7 @@ public async Task> BindAsync(Func>> selector) { ArgumentNullException.ThrowIfNull(selector); - return Succeeded ? await selector().ConfigureAwait(false) : Error(_error!); + return Succeeded ? await ThrowHelper.Awaitable(selector(), nameof(BindAsync), nameof(selector)).ConfigureAwait(false) : Error(_error!); } /// Gives an error a second chance with an asynchronous function. A success is kept unchanged. @@ -257,7 +257,7 @@ public async Task> RecoverAsync(Func> { ArgumentNullException.ThrowIfNull(selector); - return Succeeded ? this : await selector(_error!).ConfigureAwait(false); + return Succeeded ? this : await ThrowHelper.Awaitable(selector(_error!), nameof(RecoverAsync), nameof(selector)).ConfigureAwait(false); } /// Unwraps the error, if this is one. diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 7250186..810bf1d 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -1049,6 +1049,7 @@ public Result Traverse(Func> selector) where T /// The type of the error. /// A success with every produced value, in order, or the first error. /// returned an uninitialized result. + /// returned no task. public async Task, TError>> TraverseAsync(Func>> selector) where TResult : notnull where TError : notnull @@ -1060,7 +1061,7 @@ public async Task, TError>> TraverseAsync, TError>.Error(error); values.Add(value); @@ -1081,6 +1082,7 @@ public async Task, TError>> TraverseAsyncThe type of the error. /// A success with every produced value, in order, or the first error. /// returned an uninitialized result. + /// returned no task. /// was cancelled before an operation started. public async Task, TError>> TraverseAsync(Func>> selector, CancellationToken cancellationToken) where TResult : notnull @@ -1095,7 +1097,7 @@ public async Task, TError>> TraverseAsync, TError>.Error(error); values.Add(value); @@ -1113,6 +1115,7 @@ public async Task, TError>> TraverseAsyncThe type of the error. /// A success if every command succeeded, or the first error. /// returned an uninitialized result. + /// returned no task. public async Task> TraverseAsync(Func>> selector) where TError : notnull { ArgumentNullException.ThrowIfNull(source); @@ -1120,7 +1123,7 @@ public async Task> TraverseAsync(Func.Error(error); } @@ -1138,6 +1141,7 @@ public async Task> TraverseAsync(FuncThe type of the error. /// A success if every command succeeded, or the first error. /// returned an uninitialized result. + /// returned no task. /// was cancelled before a command started. public async Task> TraverseAsync(Func>> selector, CancellationToken cancellationToken) where TError : notnull { @@ -1148,7 +1152,7 @@ public async Task> TraverseAsync(Func.Error(error); } diff --git a/src/CodoMetis.TypeKit/ResultT.cs b/src/CodoMetis.TypeKit/ResultT.cs index 3a19402..1c05bfc 100644 --- a/src/CodoMetis.TypeKit/ResultT.cs +++ b/src/CodoMetis.TypeKit/ResultT.cs @@ -275,7 +275,7 @@ public async Task> TapAsync(Func action) ArgumentNullException.ThrowIfNull(action); if (Succeeded) - await action(_value!).ConfigureAwait(false); + await ThrowHelper.Awaitable(action(_value!), nameof(TapAsync), nameof(action)).ConfigureAwait(false); return this; } @@ -289,7 +289,7 @@ public async Task> TapErrorAsync(Func action) ArgumentNullException.ThrowIfNull(action); if (!Succeeded) - await action(_error!).ConfigureAwait(false); + await ThrowHelper.Awaitable(action(_error!), nameof(TapErrorAsync), nameof(action)).ConfigureAwait(false); return this; } @@ -304,7 +304,7 @@ public async Task> MapAsync(Func.Success(await selector(_value!).ConfigureAwait(false)) + ? Result.Success(await ThrowHelper.Awaitable(selector(_value!), nameof(MapAsync), nameof(selector)).ConfigureAwait(false)) : Result.Error(_error!); } @@ -317,7 +317,7 @@ public async Task> BindAsync(Func.Error(_error!); + return Succeeded ? await ThrowHelper.Awaitable(selector(_value!), nameof(BindAsync), nameof(selector)).ConfigureAwait(false) : Result.Error(_error!); } /// @@ -331,7 +331,7 @@ public async Task> BindAsync(Func>> select { ArgumentNullException.ThrowIfNull(selector); - return Succeeded ? await selector(_value!).ConfigureAwait(false) : Result.Error(_error!); + return Succeeded ? await ThrowHelper.Awaitable(selector(_value!), nameof(BindAsync), nameof(selector)).ConfigureAwait(false) : Result.Error(_error!); } /// Gives an error a second chance with an asynchronous function. A success is kept unchanged. @@ -342,7 +342,7 @@ public async Task> RecoverAsync(FuncUnwraps the value or the error. diff --git a/src/CodoMetis.TypeKit/ThrowHelper.cs b/src/CodoMetis.TypeKit/ThrowHelper.cs index a12db43..5237f2d 100644 --- a/src/CodoMetis.TypeKit/ThrowHelper.cs +++ b/src/CodoMetis.TypeKit/ThrowHelper.cs @@ -42,6 +42,22 @@ internal static class ThrowHelper /// The exception, to throw. public static ArgumentNullException NullTask(string paramName) => new(paramName, NullTaskMessage); + /// + /// What an asynchronous combinator throws for a delegate that returned a null task, as + /// CatchAsync does: awaited, it was a naming nothing. + /// + /// What the delegate returned. + /// The combinator, named in the message. + /// The delegate's parameter. + /// The task, never null. + public static Task Awaitable(Task? task, string method, string paramName) => task ?? throw NullTaskFrom(method, paramName); + + /// + public static Task Awaitable(Task? task, string method, string paramName) => task ?? throw NullTaskFrom(method, paramName); + + private static ArgumentNullException NullTaskFrom(string method, string paramName) => + new(paramName, $"The delegate given to {method} returned no task: a null cannot be awaited. Return the task, or make the lambda async.{Refusals.NullContent}"); + /// What Patch<T>.Map throws for a selector that returned null. /// The selector's parameter. /// The exception, to throw. diff --git a/test/CodoMetis.TypeKit.Tests/NullTaskTests.cs b/test/CodoMetis.TypeKit.Tests/NullTaskTests.cs new file mode 100644 index 0000000..b203c37 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/NullTaskTests.cs @@ -0,0 +1,55 @@ +namespace CodoMetis.TypeKit.Tests; + +/// +/// A delegate that returns no task is refused by every asynchronous combinator, as CatchAsync +/// refuses it: an naming the combinator and the delegate, and +/// linking the README. Awaited, the null was a naming nothing. +/// +public sealed class NullTaskTests +{ + private static readonly Option Some = Option.Some(1); + private static readonly Result Success = Result.Success(1); + private static readonly Result Failure = Result.Error("e"); + private static readonly Result CommandSuccess = Result.Success(); + private static readonly Result CommandFailure = Result.Error("e"); + private static readonly int[] Elements = [1, 2]; + + public static TheoryData Calls => [.. Call.Keys]; + + private static readonly Dictionary Run)> Call = new() + { + ["TraverseAsync(selector)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync(_ => (Task>)null!)), + ["TraverseAsync(selector, token)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync((_, _) => (Task>)null!, CancellationToken.None)), + ["command TraverseAsync(selector)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync(_ => (Task>)null!)), + ["command TraverseAsync(selector, token)"] = ("TraverseAsync", "selector", () => Elements.TraverseAsync((_, _) => (Task>)null!, CancellationToken.None)), + ["Option.MapAsync"] = ("MapAsync", "selector", () => Some.MapAsync(_ => (Task)null!)), + ["Option.BindAsync"] = ("BindAsync", "selector", () => Some.BindAsync(_ => (Task>)null!)), + ["Option.TapAsync"] = ("TapAsync", "action", () => Some.TapAsync(_ => null!)), + ["Result.MapAsync"] = ("MapAsync", "selector", () => Success.MapAsync(_ => (Task)null!)), + ["Result.BindAsync"] = ("BindAsync", "selector", () => Success.BindAsync(_ => (Task>)null!)), + ["Result.BindAsync to a command"] = ("BindAsync", "selector", () => Success.BindAsync(_ => (Task>)null!)), + ["Result.TapAsync"] = ("TapAsync", "action", () => Success.TapAsync(_ => null!)), + ["Result.TapErrorAsync"] = ("TapErrorAsync", "action", () => Failure.TapErrorAsync(_ => null!)), + ["Result.RecoverAsync"] = ("RecoverAsync", "selector", () => Failure.RecoverAsync(_ => null!)), + ["Result.MapAsync"] = ("MapAsync", "selector", () => CommandSuccess.MapAsync(() => (Task)null!)), + ["Result.BindAsync"] = ("BindAsync", "selector", () => CommandSuccess.BindAsync(() => null!)), + ["Result.TapAsync"] = ("TapAsync", "action", () => CommandSuccess.TapAsync(() => null!)), + ["Result.TapErrorAsync"] = ("TapErrorAsync", "action", () => CommandFailure.TapErrorAsync(_ => null!)), + ["Result.RecoverAsync"] = ("RecoverAsync", "selector", () => CommandFailure.RecoverAsync(_ => null!)), + ["a pending Option's MapAsync"] = ("MapAsync", "selector", () => Task.FromResult(Some).MapAsync(_ => (Task)null!)), + ["a pending Result's BindAsync"] = ("BindAsync", "selector", () => Task.FromResult(Success).BindAsync(_ => (Task>)null!)), + }; + + [Theory] + [MemberData(nameof(Calls))] + public async Task A_delegate_that_returns_no_task_is_refused_by_name(string call) + { + var (method, parameter, run) = Call[call]; + + var refusal = await Should.ThrowAsync(run); + + refusal.ParamName.ShouldBe(parameter); + refusal.Message.ShouldStartWith($"The delegate given to {method} returned no task"); + refusal.Message.ShouldContain("#a-null-in-an-option-or-a-result"); + } +} From 691cf94a7873f5f77979b7765a1172ae28c1b668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:27:18 +0200 Subject: [PATCH 060/147] docs: the diagnostic ranges include CMTK1022, and a fault's blank detail is named as the ArgumentException it is Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- docs/value-objects.md | 2 +- src/CodoMetis.TypeKit/README.md | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 66bd19e..05ad63e 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ compiled and its stated outputs are tested, as the READMEs' are. source-generated context an enum-backed value object needs its enum listed ([JSON](src/CodoMetis.TypeKit.Generators/README.md#json)). - **Loud failures.** A declaration that cannot be generated is a build error naming the - declaration, CMTK1000 to CMTK1021, never a type with nothing in it. The analyzers make `default` + declaration, CMTK1000 to CMTK1022, never a type with nothing in it. The analyzers make `default` of a value object, an `Option` or a `Result` an error. - **Discovery by interface.** The EF Core and OpenAPI satellites recognise a value object by the attribute the generators put beside `IValueObject<,>`, whose type arguments are constrained to that @@ -206,7 +206,7 @@ references `CodoMetis.TypeKit.Generators` and in every project that references t object is declared by implementing its interface, with no attribute and nothing registered. Templates write the generated members as ordinary C#, which a build can write out for you to read ([Reading the generated code](src/CodoMetis.TypeKit.Generators/README.md#reading-the-generated-code)). -A declaration that cannot be generated fails the build with its name on it, CMTK1000 to CMTK1021, +A declaration that cannot be generated fails the build with its name on it, CMTK1000 to CMTK1022, instead of compiling to less than it appears to. And Metalama runs the analyzers on the code it transformed, which is how they reach Razor components. diff --git a/docs/value-objects.md b/docs/value-objects.md index 56f467d..0600dde 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -271,5 +271,5 @@ string outcome = CreateAll(["ops@example.com", "nobod Set `true` in the declaring project and read `obj/Debug/net10.0/metalama/`: every generated member is there as ordinary -C#. A declaration the generators cannot complete fails the build with CMTK1000 to CMTK1012, listed in +C#. A declaration the generators cannot complete fails the build with CMTK1000 to CMTK1012 or CMTK1022, listed in the [Generators README](../src/CodoMetis.TypeKit.Generators/README.md#build-errors). diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 7b4bd22..18981db 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -205,7 +205,8 @@ does not accept a `Result`, and `MapError` turns the exception int **A fault with a sentence.** A fault is one of a fixed set, an enum's member or a closed set's, so what varies per occurrence travels beside it: `Fault` holds the fault, its `Reason`, and an optional `Detail`. `Fault.Of(OrderFault.NotAmendable, "The order shipped on 3 May.")` infers the type, -and `Fault.Of(OrderFault.NotFound)` is one without a detail. A bare fault does not convert to one, so a +and `Fault.Of(OrderFault.NotFound)` is one without a detail; a blank detail throws +`ArgumentException`, since none is `null`. A bare fault does not convert to one, so a comparison with it does not compile: `fault.Reason == OrderFault.NotFound` is the one meant. CodoMetis.TypeKit.AspNetCore answers a `Result>` as it answers the fault, with the detail as the problem's `detail`. A closed set whose members are faults declares `IFaultSet`, From 780353bf0b76be4ed78edda98c9ffd3b4c86e458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:14:47 +0200 Subject: [PATCH 061/147] fix: CMTK0003 sees ConfigureAwait and WaitAsync hand on the task they are called on `await held.ConfigureAwait(false);`, `await (dry ? held : other).ConfigureAwait(false);` and `await held.WaitAsync(t);` reported an Error naming ConfigureAwait or WaitAsync on a task the caller still holds. Both are now looked through, every overload, on Task and ValueTask: a held task stays silent, and a call's task is still the call's, reported naming it, awaited or not, waited for, converted to a plain Task, or picked by a branch. A pass-through no longer counts as a call that made results for a collection (`Task.WhenAll(held.WaitAsync(t), other)`). Co-Authored-By: Claude Opus 5.5 --- .../IgnoredOutcomeAnalyzer.cs | 71 +++++++++++++----- src/CodoMetis.TypeKit.Analyzers/README.md | 6 +- .../IgnoredOutcomeAnalyzerTests.cs | 75 +++++++++++++++++++ 3 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index b47fa1f..c12dc42 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -27,7 +27,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// in a method returning Task, Func<Task> f = () => svc.DoAsync();. The implicit /// conversion of a call's task is reported; an explicit (Task) cast says the drop is intended. /// A call reached through a branch, await (dry ? CancelAsync(id) : ShipAsync(id)); or a -/// switch expression, is each branch's call, reported at the branch. +/// switch expression, is each branch's call, reported at the branch. ConfigureAwait and +/// WaitAsync hand on the task they are called on: through them a call's task is still the +/// call's, and a task the caller holds, await held.ConfigureAwait(false);, drops nothing. /// A ValueTask<T> has no conversion to ValueTask. A method group converts the same /// way to a delegate that returns Task: Func<Guid, Task> f = orders.CancelAsync;, or /// bus.Subscribe<Guid>(orders.CancelAsync) for a Func<T, Task> parameter. A cast @@ -131,41 +133,44 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym var awaited = expression is IAwaitOperation { Operation: var inner } ? inner : null; var dropped = Unwrap(awaited ?? expression); - foreach (var call in Producers(dropped)) - { - // await LoadAsync().ConfigureAwait(false): the result is LoadAsync's, and so is the name. - var invocation = call is { TargetMethod.Name: "ConfigureAwait", Instance: IInvocationOperation configured } ? configured : call; + // await LoadAsync().ConfigureAwait(false): the result is LoadAsync's, and so is the name; on a + // task still held, await held.WaitAsync(t), nothing is dropped. + var passedOn = tasks.PassedOn(dropped); - // The statement's own call keeps the statement's type and location; a branch's call its own. - var own = ReferenceEquals(call, dropped); - var type = own ? expression.Type : awaited is null ? invocation.Type : tasks.Awaited(invocation.Type); + foreach (var invocation in Producers(dropped, tasks)) + { + // The statement's own call keeps the statement's location, and its type unless a + // pass-through stands between them; a branch's call keeps its own. + var own = ReferenceEquals(invocation, passedOn); + var type = own && ReferenceEquals(invocation, dropped) ? expression.Type : awaited is null ? invocation.Type : tasks.Awaited(invocation.Type); if (DroppedOutcome(type, invocation, symbols, tasks) is not { } outcome) continue; - context.Report(Diagnostic.Create(Rule, (own ? expression : call).Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + context.Report(Diagnostic.Create(Rule, (own ? expression : invocation).Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); } } /// /// The calls whose values drops as one: the call itself, or each - /// branch's call of a conditional or a switch expression, through implicit conversions and - /// null-conditional accesses. A branch that is no call, a variable or a literal, drops nothing of + /// branch's call of a conditional or a switch expression, through implicit conversions, + /// null-conditional accesses and the task's ConfigureAwait and WaitAsync, which hand on + /// the task they are called on. A branch that is no call, a variable or a literal, drops nothing of /// its own. /// - private static IEnumerable Producers(IOperation operation) + private static IEnumerable Producers(IOperation operation, Tasks tasks) { - switch (Unwrap(operation)) + switch (tasks.PassedOn(operation)) { case IInvocationOperation invocation: yield return invocation; break; case IConditionalOperation { Type: not null, WhenTrue: { } whenTrue, WhenFalse: { } whenFalse }: - foreach (var producer in Producers(whenTrue)) yield return producer; - foreach (var producer in Producers(whenFalse)) yield return producer; + foreach (var producer in Producers(whenTrue, tasks)) yield return producer; + foreach (var producer in Producers(whenFalse, tasks)) yield return producer; break; case ISwitchExpressionOperation chosen: foreach (var arm in chosen.Arms) { - foreach (var producer in Producers(arm.Value)) yield return producer; + foreach (var producer in Producers(arm.Value, tasks)) yield return producer; } break; @@ -203,8 +208,10 @@ private static bool MadeResults(IInvocationOperation invocation, TypeKitSymbols { if (!Given(invocation, symbols)) return true; + // held.WaitAsync(t) hands on a task that is still held; a call under one is a descendant of its own. return invocation.Descendants().Any(operation => operation switch { + IInvocationOperation call when tasks.PassesOn(call) => false, IInvocationOperation call => (symbols.OutcomeName(call.Type) ?? tasks.PendingOutcome(call.Type, symbols)) is not null && !ReturnsItsStoredReceiver(call, symbols) || tasks.SequenceOutcome(call.Type, symbols) is not null && !Given(call, symbols), IMethodReferenceOperation reference => (symbols.OutcomeName(reference.Method.ReturnType) ?? tasks.PendingOutcome(reference.Method.ReturnType, symbols)) is not null, @@ -232,7 +239,10 @@ private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitS if (!toPlainTask && !tasks.HoldsPlainTasks(conversion.Type)) return; // The call, or each branch's call where a conditional or a switch expression was converted whole. - foreach (var invocation in Producers(conversion.Operand)) + // A pass-through between the conversion and the call, LoadAsync().WaitAsync(t), is reported whole. + var passedOn = tasks.PassedOn(conversion.Operand); + + foreach (var invocation in Producers(conversion.Operand, tasks)) { var outcome = toPlainTask ? tasks.PendingOutcome(invocation.Type, symbols) @@ -241,7 +251,8 @@ private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitS if (outcome is null) continue; - context.Report(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + var reported = ReferenceEquals(invocation, passedOn) ? conversion.Operand : invocation; + context.Report(Diagnostic.Create(Rule, reported.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); } } @@ -256,7 +267,7 @@ private static void AnalyzeWait(OperationAnalysisContext context, TypeKitSymbols var wait = (IInvocationOperation)context.Operation; if (wait.TargetMethod is not { Name: "Wait", IsStatic: false } method || !SymbolEqualityComparer.Default.Equals(method.ContainingType, tasks.Plain)) return; - if (wait.Instance is null || Unwrap(wait.Instance) is not IInvocationOperation invocation) return; + if (wait.Instance is null || tasks.PassedOn(wait.Instance) is not IInvocationOperation invocation) return; var outcome = tasks.PendingOutcome(invocation.Type, symbols) ?? DroppedCollection(tasks.CollectionOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks); @@ -379,6 +390,28 @@ private sealed class Tasks( { public INamedTypeSymbol? Plain { get; } = plain; + /// + /// What hands on, through implicit conversions, null-conditional + /// accesses and any number of calls: held.ConfigureAwait(false) is + /// held, LoadAsync().WaitAsync(t) the LoadAsync() call. + /// + public IOperation PassedOn(IOperation operation) + { + var current = Unwrap(operation); + while (current is IInvocationOperation { Instance: { } instance } call && PassesOn(call)) current = Unwrap(instance); + + return current; + } + + /// + /// ConfigureAwait and WaitAsync of Task<T> or ValueTask<T>, + /// every overload: each completes with the task's own result and makes none. + /// + public bool PassesOn(IInvocationOperation call) => + call.TargetMethod is { IsStatic: false, Name: "ConfigureAwait" or "WaitAsync" } method + && (SymbolEqualityComparer.Default.Equals(method.ContainingType.OriginalDefinition, generic) + || SymbolEqualityComparer.Default.Equals(method.ContainingType.OriginalDefinition, genericValueTask)); + /// A Task or ValueTask of a result, not awaited: the result is dropped with it. public string? PendingOutcome(ITypeSymbol? type, TypeKitSymbols symbols) => symbols.OutcomeName(Awaited(type)); diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 7251956..4211830 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -151,8 +151,10 @@ Func cancel = orders.CancelAsync; // CMTK0003 bus.Subscribe(orders.CancelAsync); // CMTK0003: the parameter is a Func ``` -A task you still hold is not dropped, and an explicit cast, to `Task` or to the delegate type, says -the drop is intended. A `ValueTask` has no conversion to `ValueTask`. +A task you still hold is not dropped, awaited as it is or through `ConfigureAwait` or `WaitAsync`, +which hand on the task they are called on; through them, a call's task is still that call's +(`await orders.CancelAsync(id).ConfigureAwait(false);` is reported, naming `CancelAsync`). An +explicit cast, to `Task` or to the delegate type, says the drop is intended. A `ValueTask` has no conversion to `ValueTask`. A collection of results is dropped as silently as one result: diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index f614748..0e986f3 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -162,6 +162,81 @@ public static void Run(Patch quantity, Patch nickname, List se """) .RunAsync(TestContext.Current.CancellationToken); + /// + /// ConfigureAwait and WaitAsync hand on the task they are called on: a task the caller + /// still holds keeps its result through them, as it does through await held;. They were + /// reported as the producers of the result, at Error. + /// + [Fact] + public Task A_held_task_passed_through_ConfigureAwait_or_WaitAsync_stays_silent() => + Test( + """ + public static class Caller + { + private static readonly Task> Field = Task.FromResult>(1); + + public static async Task Run(Task> held, Task> other, ValueTask> valueHeld, bool dry, System.Threading.CancellationToken t) + { + await held; + await held.ConfigureAwait(false); + await held.ConfigureAwait(ConfigureAwaitOptions.None); + await Field.ConfigureAwait(false); + await (dry ? held : other).ConfigureAwait(false); + await valueHeld.ConfigureAwait(false); + await held.WaitAsync(t); + await held.WaitAsync(TimeSpan.FromSeconds(1)); + await held.WaitAsync(TimeSpan.FromSeconds(1), t); + await held.WaitAsync(TimeSpan.FromSeconds(1), TimeProvider.System); + await held.WaitAsync(TimeSpan.FromSeconds(1), TimeProvider.System, t); + await held.WaitAsync(t).ConfigureAwait(false); + held.WaitAsync(t); + held.ConfigureAwait(false); + held.WaitAsync(t).Wait(); + await Task.WhenAll(held.WaitAsync(t), other); + } + + public static Task Handed(Task> held, System.Threading.CancellationToken t) => held.WaitAsync(t); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A task a call made, passed through ConfigureAwait or WaitAsync, is still that + /// call's result, dropped and named after the call: awaited, not awaited, waited for, converted to + /// a plain task, or picked by a branch. + /// + [Fact] + public Task A_call_s_task_passed_through_ConfigureAwait_or_WaitAsync_reports_naming_the_call() => + Test( + """ + public static class Caller + { + public static async Task Run(Service service, Task> held, bool dry, System.Threading.CancellationToken t) + { + {|#0:await service.LoadAsync().ConfigureAwait(ConfigureAwaitOptions.None)|}; + {|#1:await service.SaveAsync().ConfigureAwait(false)|}; + {|#2:await service.LoadAsync().WaitAsync(t)|}; + {|#3:await service.LoadAsync().WaitAsync(TimeSpan.FromSeconds(1), TimeProvider.System, t).ConfigureAwait(false)|}; + {|#4:service.LoadAsync().WaitAsync(t)|}; + {|#5:service.LoadAsync().ConfigureAwait(false)|}; + await (dry ? held : {|#6:service.LoadAsync()|}).ConfigureAwait(false); + {|#7:service.LoadAsync().WaitAsync(t).Wait()|}; + } + + public static Task Handed(Service service, System.Threading.CancellationToken t) => {|#8:service.LoadAsync().WaitAsync(t)|}; + } + """, + Cmtk0003(0, "Result", "LoadAsync"), + Cmtk0003(1, "Result", "SaveAsync"), + Cmtk0003(2, "Result", "LoadAsync"), + Cmtk0003(3, "Result", "LoadAsync"), + Cmtk0003(4, "Result", "LoadAsync"), + Cmtk0003(5, "Result", "LoadAsync"), + Cmtk0003(6, "Result", "LoadAsync"), + Cmtk0003(7, "Result", "LoadAsync"), + Cmtk0003(8, "Result", "LoadAsync")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// Tap, TapAsync, TapError and TapErrorAsync return their receiver: /// dropping it loses nothing only when the receiver is still held. From e269ec81a39a18ee7abace1968cfab578dc0dd6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:19:06 +0200 Subject: [PATCH 062/147] fix: CMTK0003 reports a call picked by ??, a dropped tuple and a dropped dictionary of results `await (held ?? svc.LoadAsync());` and `() => held ?? svc.LoadAsync()` were silent: a coalesce is now a branch like a conditional or a switch expression, each call reported at itself. A tuple that holds a result, a task of one or a nested tuple holding one (`svc.Pair();`, `await svc.PairAsync();`, a conversion to a plain Task, a method group) is held to the collection rule, as "tuple holding a Result". A collection's element may be a KeyValuePair whose value is a result or a task of one (`svc.Map();`, `.ToDictionary(...)`, a materialised sequence of pairs). A lazy sequence drained without being read, `svc.Each(ids).Count();`, is out of reach and listed so in the Analyzers README. Co-Authored-By: Claude Opus 5.5 --- .../IgnoredOutcomeAnalyzer.cs | 85 +++++++++++++----- src/CodoMetis.TypeKit.Analyzers/README.md | 10 ++- .../IgnoredOutcomeAnalyzerTests.cs | 88 +++++++++++++++++++ 3 files changed, 158 insertions(+), 25 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index c12dc42..66aabd2 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -26,8 +26,8 @@ namespace CodoMetis.TypeKit.Analyzers; /// silently: Task Cancel(Guid id) => orders.CancelAsync(id);, return svc.DoAsync(); /// in a method returning Task, Func<Task> f = () => svc.DoAsync();. The implicit /// conversion of a call's task is reported; an explicit (Task) cast says the drop is intended. -/// A call reached through a branch, await (dry ? CancelAsync(id) : ShipAsync(id)); or a -/// switch expression, is each branch's call, reported at the branch. ConfigureAwait and +/// A call reached through a branch, await (dry ? CancelAsync(id) : ShipAsync(id));, a +/// switch expression or held ?? CancelAsync(id), is each branch's call, reported at the branch. ConfigureAwait and /// WaitAsync hand on the task they are called on: through them a call's task is still the /// call's, and a task the caller holds, await held.ConfigureAwait(false);, drops nothing. /// A ValueTask<T> has no conversion to ValueTask. A method group converts the same @@ -39,8 +39,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// A collection of results is dropped as silently: await Task.WhenAll(ids.Select(orders.CancelAsync)); /// awaits a Result[] nobody reads, and ids.Select(orders.Cancel).ToList(); builds a list /// of them and forgets it. A collection is an array, or a type that is or implements -/// ICollection<T> or IReadOnlyCollection<T>, of results or of tasks of them: its -/// elements exist. A lazy IEnumerable<T> has run nothing, and CA1806 reports a LINQ query +/// ICollection<T> or IReadOnlyCollection<T>, of results, of tasks of them or of +/// key-value pairs whose value is one: its elements exist. A tuple that holds a result is held to the +/// same rule. A lazy IEnumerable<T> has run nothing, and CA1806 reports a LINQ query /// that is dropped. The collection is reported when the statement made the results in it /// (). One that only passes on results the statement was given, as /// await Task.WhenAll(first, second) over tasks in variables or pending.ToList() do, @@ -109,7 +110,8 @@ public override void Initialize(AnalysisContext context) start.Compilation.GetSpecialType(SpecialType.System_Collections_Generic_IReadOnlyCollection_T), start.Compilation.GetSpecialType(SpecialType.System_Collections_Generic_IEnumerable_T), start.Compilation.GetTypeByMetadataName("System.Collections.Generic.IAsyncEnumerable`1"), - start.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")); + start.Compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"), + start.Compilation.GetTypeByMetadataName("System.Collections.Generic.KeyValuePair`2")); start.RegisterOperationAction(operation => Analyze(operation, symbols, tasks), OperationKind.ExpressionStatement); start.RegisterOperationAction(operation => AnalyzeConversion(operation, symbols, tasks), OperationKind.Conversion); @@ -167,6 +169,10 @@ private static IEnumerable Producers(IOperation operation, foreach (var producer in Producers(whenTrue, tasks)) yield return producer; foreach (var producer in Producers(whenFalse, tasks)) yield return producer; break; + case ICoalesceOperation { Value: { } value, WhenNull: { } whenNull }: + foreach (var producer in Producers(value, tasks)) yield return producer; + foreach (var producer in Producers(whenNull, tasks)) yield return producer; + break; case ISwitchExpressionOperation chosen: foreach (var arm in chosen.Arms) { @@ -179,19 +185,23 @@ private static IEnumerable Producers(IOperation operation, /// /// What a value of , produced by , drops: a - /// Result or Option or a task of one, or a collection of them that the statement made, - /// awaited or not. + /// Result or Option or a task of one, or a collection or a tuple of them that the + /// statement made, awaited or not. /// private static string? DroppedOutcome(ITypeSymbol? type, IInvocationOperation invocation, TypeKitSymbols symbols, Tasks tasks) { if ((symbols.OutcomeName(type) ?? tasks.PendingOutcome(type, symbols)) is { } outcome) return ReturnsItsStoredReceiver(invocation, symbols) ? null : outcome; - return DroppedCollection(tasks.CollectionOutcome(type, symbols) ?? tasks.CollectionOutcome(tasks.Awaited(type), symbols), invocation, symbols, tasks); + return DroppedHolder(tasks.HolderOutcome(type, symbols) ?? tasks.HolderOutcome(tasks.Awaited(type), symbols), invocation, symbols, tasks); } private static string? DroppedCollection(string? element, IInvocationOperation invocation, TypeKitSymbols symbols, Tasks tasks) => - element is not null && MadeResults(invocation, symbols, tasks) ? $"collection of {element}s" : null; + DroppedHolder(element is null ? null : $"collection of {element}s", invocation, symbols, tasks); + + /// , a collection or a tuple of results, when the statement made the results in it. + private static string? DroppedHolder(string? holder, IInvocationOperation invocation, TypeKitSymbols symbols, Tasks tasks) => + holder is not null && MadeResults(invocation, symbols, tasks) ? holder : null; /// /// Whether the results in the collection returns were made in the @@ -246,7 +256,7 @@ private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitS { var outcome = toPlainTask ? tasks.PendingOutcome(invocation.Type, symbols) - ?? DroppedCollection(tasks.CollectionOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks) + ?? DroppedHolder(tasks.HolderOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks) : DroppedCollection(tasks.PendingOutcome(tasks.ElementOfSequence(invocation.Type), symbols), invocation, symbols, tasks); if (outcome is null) continue; @@ -270,7 +280,7 @@ private static void AnalyzeWait(OperationAnalysisContext context, TypeKitSymbols if (wait.Instance is null || tasks.PassedOn(wait.Instance) is not IInvocationOperation invocation) return; var outcome = tasks.PendingOutcome(invocation.Type, symbols) - ?? DroppedCollection(tasks.CollectionOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks); + ?? DroppedHolder(tasks.HolderOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks); if (outcome is null) return; context.Report(Diagnostic.Create(Rule, wait.Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); @@ -306,10 +316,9 @@ private static void AnalyzeMethodGroup(OperationAnalysisContext context, TypeKit if (creation.Target is not IMethodReferenceOperation reference || creation.Syntax is CastExpressionSyntax) return; if (creation.Type is not INamedTypeSymbol { DelegateInvokeMethod: { } invoke } || !SymbolEqualityComparer.Default.Equals(invoke.ReturnType, tasks.Plain)) return; - // A method that answers a collection of results makes them on every call through the delegate. + // A method that answers a collection or a tuple of results makes them on every call through the delegate. var returned = reference.Method.ReturnType; - var outcome = tasks.PendingOutcome(returned, symbols) - ?? (tasks.CollectionOutcome(tasks.Awaited(returned), symbols) is { } element ? $"collection of {element}s" : null); + var outcome = tasks.PendingOutcome(returned, symbols) ?? tasks.HolderOutcome(tasks.Awaited(returned), symbols); if (outcome is null) return; context.Report(Diagnostic.Create(Rule, reference.Syntax.GetLocation(), outcome, reference.Method.Name)); @@ -386,7 +395,8 @@ private sealed class Tasks( INamedTypeSymbol? readOnlyCollection, INamedTypeSymbol? sequence, INamedTypeSymbol? asyncSequence, - INamedTypeSymbol? readOnlySpan) + INamedTypeSymbol? readOnlySpan, + INamedTypeSymbol? keyValuePair) { public INamedTypeSymbol? Plain { get; } = plain; @@ -422,12 +432,23 @@ public bool PassesOn(IInvocationOperation call) => ? named.TypeArguments[0] : null; + /// + /// What a value of holds results in, as the message names it: a + /// collection (collection of Results) or a + /// tuple (tuple holding a Result) of them. + /// + public string? HolderOutcome(ITypeSymbol? type, TypeKitSymbols symbols) => + CollectionOutcome(type, symbols) is { } element ? $"collection of {element}s" + : TupleOutcome(type, symbols) is { } held ? $"tuple holding {(held is "Option" ? "an" : "a")} {held}" + : null; + /// /// Result or Option when is a collection whose elements - /// exist, of results or of tasks of them: an array, or a type that is or implements - /// ICollection<T> or IReadOnlyCollection<T>. A lazy sequence is none. + /// exist, of results, of tasks of them or of key-value pairs whose value is one: an array, or a + /// type that is or implements ICollection<T> or IReadOnlyCollection<T>, a + /// dictionary included. A lazy sequence is none. /// - public string? CollectionOutcome(ITypeSymbol? type, TypeKitSymbols symbols) + private string? CollectionOutcome(ITypeSymbol? type, TypeKitSymbols symbols) { var element = type switch { @@ -436,17 +457,35 @@ public bool PassesOn(IInvocationOperation call) => _ => null }; - return symbols.OutcomeName(element) ?? PendingOutcome(element, symbols); + return ElementOutcome(element, symbols); + } + + /// + /// Result or Option when is a tuple, nullable or not, with + /// an element that is one, a task of one, or a tuple holding one. + /// + private string? TupleOutcome(ITypeSymbol? type, TypeKitSymbols symbols) + { + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable) type = nullable.TypeArguments[0]; + if (type is not INamedTypeSymbol { IsTupleType: true } tuple) return null; + + return tuple.TupleElements + .Select(element => symbols.OutcomeName(element.Type) ?? PendingOutcome(element.Type, symbols) ?? TupleOutcome(element.Type, symbols)) + .FirstOrDefault(outcome => outcome is not null); } /// /// Result or Option when is a sequence, lazy or not, of - /// results or of tasks of them: an array, or a type that is or implements - /// IEnumerable<T> or IAsyncEnumerable<T>. + /// results, of tasks of them or of key-value pairs whose value is one: an array, or a type that + /// is or implements IEnumerable<T> or IAsyncEnumerable<T>. /// - public string? SequenceOutcome(ITypeSymbol? type, TypeKitSymbols symbols) + public string? SequenceOutcome(ITypeSymbol? type, TypeKitSymbols symbols) => ElementOutcome(ElementOfSequence(type), symbols); + + /// A collection's element that is a result, a task of one, or a KeyValuePair whose value is either. + private string? ElementOutcome(ITypeSymbol? element, TypeKitSymbols symbols) { - var element = ElementOfSequence(type); + if (element is INamedTypeSymbol { TypeArguments.Length: 2 } pair && SymbolEqualityComparer.Default.Equals(pair.OriginalDefinition, keyValuePair)) + element = pair.TypeArguments[1]; return symbols.OutcomeName(element) ?? PendingOutcome(element, symbols); } diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 4211830..3990afd 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -14,7 +14,7 @@ appears to: an instance that passed no factory, or a value object that was never |---|---|---| | CMTK0001 | Error | `default`, `default(T)`, `new T()`, `new()` and `new T { }` of a value object, an `Option`, a `Result`, or a struct marked `[RequireCustomInitialization]`. Also through a type parameter whose constraints make it one of those. A default that is only compared (`id == default`, `id.Equals(default)`, `ThrowIfEqual(id, default)`, `Assert.NotEqual(default, id)`) is a guard and is not reported, nor is one assigned to an `out` parameter of a Try method that returns `bool`. | | CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | -| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection of them that the statement made, dropped. The drops: a statement, awaited or not; a conversion of its `Task` to a plain `Task`, a method group's included; `Wait`; a loop that never reads it; a branch of a conditional or a `switch` expression that picked the call. `_` says the drop is meant. | +| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. The drops: a statement, awaited or not; a conversion of its `Task` to a plain `Task`, a method group's included; `Wait`; a loop that never reads it; a branch of a conditional, a `switch` expression or a `??` that picked the call. `_` says the drop is meant. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core satellite. | | CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero is not reported. | | CMTK0006 | Warning | A field or auto-property of such a type in a class that no initializer, `required` or constructor sets, or a `required` one that a `[SetsRequiredMembers]` constructor does not set. | @@ -169,7 +169,9 @@ await Task.WhenAll(cancelFirst, cancelSecond); // silent: b ``` A collection is an array, or a type that is or implements `ICollection` or `IReadOnlyCollection` -(`List`, `ImmutableArray`, `IReadOnlyList`), of results or of tasks of them. It is reported +(`List`, `ImmutableArray`, `IReadOnlyList`), of results, of tasks of them, or of key-value +pairs whose value is one (`Dictionary>`, `ids.ToDictionary(id => id, orders.Cancel)`); +a tuple that holds one, `(Result<…>, int)`, is held to the same rule. It is reported when the statement made the results in it: a call in the statement returns a result or a task of one, a method group of such a method is passed on, or the call that returns the collection was given no result and made them itself, as `orders.CancelAll(ids);` does. A call given results that something @@ -196,6 +198,10 @@ explicit cast to the plain tasks' type says that drop is intended. A its body never reads the iteration variable. Tasks and collections that something still holds keep their results and are silent. +Out of reach: a lazy sequence of results drained by a call that reads none of them, +`orders.CancelEach(ids).Count();`. The call is given the sequence, and nothing in its signature tells +a count from a read; enumerate it with a loop that reads each result, or discard the count with `_ =`. + `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` return their receiver unchanged, and so do the `Task` continuations `TapAsync` and `TapErrorAsync`, so `result.Tap(log);` and `await pending.TapAsync(log);` on a variable, parameter or field are silent. `Find(id).Tap(log);` drops the result and is reported. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index 0e986f3..043ab1b 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -54,6 +54,14 @@ public async IAsyncEnumerable> SaveEachAsync(int[] values) foreach (var value in values) yield return await CancelAsync(value); } + public (Result, int) Pair() => (Load(), 1); + + public Task<(Result Loaded, int Count)> PairAsync() => Task.FromResult<(Result, int)>(Pair()); + + public Dictionary> Map(int[] values) => []; + + public Task>> MapAsync() => Task.FromResult>>(new Dictionary>()); + public Task RunAsync() => Task.CompletedTask; public int Count() => 1; @@ -347,6 +355,86 @@ public static void Hold(Service service) Cmtk0003(6, "collection of Results", "SaveAllAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// Neighbours of the branch, collection and conversion forms were silent: a call picked by + /// ??, awaited or converted to a plain task; a tuple that holds a result; a dictionary of + /// results, or a sequence of key-value pairs of them materialised. + /// + [Fact] + public Task A_result_dropped_through_a_coalesce_a_tuple_or_a_dictionary_reports() => + Test( + """ + public static class Neighbours + { + public static async Task Run(Service service, Task> held, int[] ids) + { + await (held ?? {|#0:service.LoadAsync()|}); + {|#1:service.Pair()|}; + {|#2:await service.PairAsync()|}; + {|#3:service.PairAsync()|}; + {|#4:service.Map(ids)|}; + {|#5:ids.ToDictionary(i => i, i => service.Save(i))|}; + {|#6:await service.MapAsync()|}; + {|#7:ids.Select(i => new KeyValuePair>(i, service.Save(i))).ToList()|}; + } + + public static Func Deferred(Service service, Task> held) => () => held ?? {|#8:service.LoadAsync()|}; + + public static Task Converted(Service service) => {|#9:service.PairAsync()|}; + + public static Task Mapped(Service service) => {|#10:service.MapAsync()|}; + + public static void Groups(Service service) + { + Func pairs = {|#11:service.PairAsync|}; + Func maps = {|#12:service.MapAsync|}; + } + } + """, + Cmtk0003(0, "Result", "LoadAsync"), + Cmtk0003(1, "tuple holding a Result", "Pair"), + Cmtk0003(2, "tuple holding a Result", "PairAsync"), + Cmtk0003(3, "tuple holding a Result", "PairAsync"), + Cmtk0003(4, "collection of Results", "Map"), + Cmtk0003(5, "collection of Results", "ToDictionary"), + Cmtk0003(6, "collection of Options", "MapAsync"), + Cmtk0003(7, "collection of Results", "ToList"), + Cmtk0003(8, "Result", "LoadAsync"), + Cmtk0003(9, "tuple holding a Result", "PairAsync"), + Cmtk0003(10, "collection of Options", "MapAsync"), + Cmtk0003(11, "tuple holding a Result", "PairAsync"), + Cmtk0003(12, "collection of Options", "MapAsync")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A coalesce of held tasks, a tuple or a dictionary that is used or discarded, and a copy of a + /// held dictionary or of a held tuple's result drop nothing. + /// + [Fact] + public Task A_coalesce_a_tuple_or_a_dictionary_that_is_held_or_used_stays_silent() => + Test( + """ + public static class Neighbours + { + public static async Task Run(Service service, Task> held, Task> other, Dictionary> saved, (Result, int) pair) + { + await (held ?? other); + _ = service.Pair(); + var (loaded, count) = service.Pair(); + _ = await service.PairAsync(); + _ = service.Map([1]); + var kept = await service.MapAsync(); + saved.ToDictionary(entry => entry.Key, entry => entry.Value); + saved.Where(entry => entry.Key > 0).ToList(); + ValueTuple.Create(pair.Item1, 1); + service.Counts().ToDictionary(c => c, c => c); + } + + public static Task Held(Task> held, Task> other) => held ?? other; + } + """) + .RunAsync(TestContext.Current.CancellationToken); + /// /// A Task of a result converts implicitly to Task, which drops the result as silently /// as a statement does: an expression-bodied member, a return, a lambda, an argument. From e4361e9d1696e34cc3735344fb3c5fb773f353fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:25:11 +0200 Subject: [PATCH 063/147] fix: CMTK0010 checks extension blocks, generic attributes and inherited or static implementations Measured on Roslyn 5.0.0, the analyzer's floor, which exposes INamedTypeSymbol.IsExtension: a member of a generic C# 14 extension block binds with the block's type arguments substituted from the receiver. `handler.Go()`, `_ = handler.Prop`, `Handler.Make()`, `Action a = handler.Go` and an extension operator were silent, since the analyzer read only a generic method's own parameters and no property reference; each is now checked at the use. Written, the block's arguments come first in the list (`handler.Take(h)`), which the arity check had skipped silently; each is now checked at its argument. `[Mark]` binds to the attribute's constructor and is now checked as its type. A static method implementing a static abstract interface method, and a base class's method that implements an interface for the derived class it is reached through (`derived.Send()`, an unqualified call inside the derived class, `DerivedFactory.Make()`), now carry the interface's constraint. Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Analyzers/README.md | 12 +- .../TypeArgumentConstraintAnalyzer.cs | 206 ++++++++++++++---- .../TypeArgumentConstraintAnalyzerTests.cs | 125 +++++++++++ 3 files changed, 303 insertions(+), 40 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 3990afd..73bd039 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -21,7 +21,7 @@ appears to: an instance that passed no factory, or a value object that was never | CMTK0007 | Info | `FromKnownGood` given a value that comes straight from a parameter. | | CMTK0008 | Warning | The wrapped values of two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves through `Equals(object)`. | | CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind. | -| CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call or a method group, written or inferred. A type parameter forwarded without the attribute is a breach too, and a method implementing an interface's method, or overriding one, carries the constraint of what it implements. | +| CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, an attribute, a call or a method group, written or inferred, and a member of a generic extension block, whose arguments the receiver gives. A type parameter forwarded without the attribute is a breach too, and a method implementing an interface's method, or overriding one, carries the constraint of what it implements, a static one and one a base class declares included. | | CMTK0011 | Error | A concrete type that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. The code fix writes or changes the constructor. | | CMTK0012 | Error | A closed set's `Match` given an argument by position, which binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | @@ -370,7 +370,15 @@ public abstract class Sound<[Abstract] TCommand> : Decider` would never be reported. The open generic +at the first forwarding parameter and `Sound` would never be reported. A generic +attribute, `[Handles]`, is a use like any other. So is a member of a C# 14 extension +block with a constrained type parameter, `extension<[Concrete] T>(T value)`: its argument comes from +the receiver, so `handler.Go()`, `handler.Count`, `Handler.Make()`, a method group and an operator are +reported at the use, and where the arguments are written, the block's come first +(`handler.Take(order)`). A method that implements an interface's method carries +its constraint, a static method implementing a `static abstract` one included, and so does a base +class's method that implements the interface for a derived class, when it is reached through the +derived class. The open generic inside its own body and an unbound `typeof(Decider<,>)` are never reported. Reflection, `MakeGenericType`, is out of reach, and the attributes carry no run-time check. diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index e955725..10e4c26 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -20,7 +22,10 @@ namespace CodoMetis.TypeKit.Analyzers; /// aspect. Every generic name written in source is checked where it is written, at the argument; /// a call whose type arguments are inferred has no such name and is checked at the call, and a /// method group whose type arguments are inferred from the delegate it converts to -/// (Action<PlaceOrder> run = Runner.Run;) at the group. +/// (Action<PlaceOrder> run = Runner.Run;) at the group. A generic attribute's name binds +/// to its constructor and is checked as its type. A member of a generic C# 14 extension block takes +/// the block's type arguments from its receiver: a call, a property, a method group or an operator is +/// checked at the use, and a written list starts with the block's arguments. /// /// /// A type parameter given as the argument satisfies a constraint only when it carries the same @@ -31,7 +36,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// /// A method that implements an interface's method, or overrides a base class's, carries the /// constraint of the method it implements whether or not it repeats the attribute, so a call through -/// the class is held to what the interface declared. A cref in a documentation comment names +/// the class is held to what the interface declared: a static method implementing a static +/// abstract one, and a base class's method implementing it for the derived class it is reached +/// through, included. A cref in a documentation comment names /// the generic's own parameters and is not a use; an array is sealed and concrete. /// /// @@ -65,10 +72,22 @@ public override void Initialize(AnalysisContext context) start.RegisterSyntaxNodeAction(node => AnalyzeGenericName(node, symbols), SyntaxKind.GenericName); start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols), OperationKind.Invocation); start.RegisterOperationAction(operation => AnalyzeMethodGroup(operation, symbols), OperationKind.MethodReference); + start.RegisterOperationAction(operation => AnalyzeProperty(operation, symbols), OperationKind.PropertyReference); + start.RegisterOperationAction( + operation => AnalyzeOperator(operation, symbols), + OperationKind.Binary, + OperationKind.Unary, + OperationKind.Increment, + OperationKind.Decrement, + OperationKind.CompoundAssignment); }); } - /// A generic name written in source, a type's or a method's: each argument against its parameter, reported at the argument. + /// + /// A generic name written in source, a type's, an attribute's or a method's: each argument against + /// its parameter, reported at the argument. A member of a generic extension block takes the + /// block's arguments first and its own after them, handler.Take<Handler, Handler>(handler). + /// private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols) { var name = (GenericNameSyntax)context.Node; @@ -78,20 +97,21 @@ private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKi var info = context.SemanticModel.GetSymbolInfo(name, context.CancellationToken); var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); - var (parameters, arguments) = symbol switch + var arguments = symbol switch { - INamedTypeSymbol type when type.IsGenericType => (type.OriginalDefinition.TypeParameters, type.TypeArguments), - IMethodSymbol method when method.IsGenericMethod => (method.OriginalDefinition.TypeParameters, method.TypeArguments), - _ => (ImmutableArray.Empty, ImmutableArray.Empty), + INamedTypeSymbol type when type.IsGenericType => Pairs(type), + // [Mark] binds to the attribute's constructor. + IMethodSymbol { MethodKind: MethodKind.Constructor, ContainingType: { IsGenericType: true } type } => Pairs(type), + IMethodSymbol method => Pairs(method), + _ => [], }; - if (parameters.Length != arguments.Length || parameters.Length != name.TypeArgumentList.Arguments.Count) return; + if (arguments.Count == 0 || arguments.Count != name.TypeArgumentList.Arguments.Count) return; - for (var i = 0; i < parameters.Length; i++) - { - foreach (var breach in Breaches(symbols, parameters[i], arguments[i])) - context.Report(Diagnostic.Create(Rule, name.TypeArgumentList.Arguments[i].GetLocation(), arguments[i].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), parameters[i].Name, Owner(symbol!), breach)); - } + var receiver = symbol is IMethodSymbol { IsGenericMethod: true } ? Receiver(name, context.SemanticModel, context.CancellationToken) : null; + + for (var i = 0; i < arguments.Count; i++) + Report(context.ReportDiagnostic, symbols, arguments[i], receiver, name.TypeArgumentList.Arguments[i].GetLocation()); } /// A call whose type arguments are inferred: nothing is written, so it is reported at the call. @@ -114,36 +134,136 @@ private static void AnalyzeMethodGroup(OperationAnalysisContext context, TypeKit AnalyzeInferred(context, symbols, reference.Method, reference.Syntax, reference.Syntax); } - private static void AnalyzeInferred(OperationAnalysisContext context, TypeKitSymbols symbols, IMethodSymbol method, SyntaxNode? name, SyntaxNode reported) + /// A property of a generic extension block, handler.Prop: the block's arguments are inferred from the receiver. + private static void AnalyzeProperty(OperationAnalysisContext context, TypeKitSymbols symbols) { - if (!method.IsGenericMethod) return; + var reference = (IPropertyReferenceOperation)context.Operation; + + AnalyzeInferred(context, symbols, reference.Property, null, reference.Syntax); + } + + /// A user-defined operator of a generic extension block, handler + 1: the block's arguments are inferred from the operands. + private static void AnalyzeOperator(OperationAnalysisContext context, TypeKitSymbols symbols) + { + var method = context.Operation switch + { + IBinaryOperation binary => binary.OperatorMethod, + IUnaryOperation unary => unary.OperatorMethod, + IIncrementOrDecrementOperation step => step.OperatorMethod, + ICompoundAssignmentOperation compound => compound.OperatorMethod, + _ => null, + }; + + if (method is not null) AnalyzeInferred(context, symbols, method, null, context.Operation.Syntax); + } - // Written type arguments are a generic name, checked where they are written. + /// + /// A member used with no generic name: the type arguments of its extension block, which are never + /// written at such a use, and its own when they are inferred, all reported at . + /// + private static void AnalyzeInferred(OperationAnalysisContext context, TypeKitSymbols symbols, ISymbol member, SyntaxNode? name, SyntaxNode reported) + { + // Written type arguments are a generic name, checked where they are written, the block's included. if (name is GenericNameSyntax || name is MemberAccessExpressionSyntax { Name: GenericNameSyntax }) return; - var parameters = method.OriginalDefinition.TypeParameters; - var arguments = method.TypeArguments; - if (parameters.Length != arguments.Length) return; + var arguments = Pairs(member); + if (arguments.Count == 0) return; + + var receiver = member is IMethodSymbol { IsGenericMethod: true } ? Receiver(name ?? reported, context.Operation.SemanticModel, context.CancellationToken) : null; + + foreach (var argument in arguments) + Report(context.ReportDiagnostic, symbols, argument, receiver, reported.GetLocation()); + } + + private static void Report(Action report, TypeKitSymbols symbols, TypeArgument argument, ITypeSymbol? receiver, Location location) + { + foreach (var breach in Breaches(symbols, argument.Parameter, argument.Argument, receiver)) + report(Diagnostic.Create(Rule, location, argument.Argument.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), argument.Parameter.Name, Owner(argument.Owner), breach)); + } + + /// A type parameter, the argument it was given, and the generic that declares it. + private readonly struct TypeArgument(ITypeParameterSymbol parameter, ITypeSymbol argument, ISymbol owner) + { + public ITypeParameterSymbol Parameter { get; } = parameter; + + public ITypeSymbol Argument { get; } = argument; - for (var i = 0; i < parameters.Length; i++) + public ISymbol Owner { get; } = owner; + } + + /// The type arguments of a generic type, in order. + private static List Pairs(INamedTypeSymbol type) => + Zip(type.OriginalDefinition.TypeParameters, type.TypeArguments, type); + + /// + /// The type arguments a member's use gives, in the order they are written: its generic extension + /// block's, then a method's own. + /// + private static List Pairs(ISymbol member) + { + var pairs = member.ContainingType is { IsExtension: true, IsGenericType: true } extension ? Pairs(extension) : []; + + if (member is IMethodSymbol { IsGenericMethod: true } method) + pairs.AddRange(Zip(method.OriginalDefinition.TypeParameters, method.TypeArguments, method)); + + return pairs; + } + + private static List Zip(ImmutableArray parameters, ImmutableArray arguments, ISymbol owner) => + parameters.Length == arguments.Length + ? parameters.Zip(arguments, (parameter, argument) => new TypeArgument(parameter, argument, owner)).ToList() + : []; + + /// + /// The type a member is reached through, whose interfaces a method may implement by inheritance: + /// derived.Send<T> binds to BaseSender.Send<T>, which implements + /// ISender.Send<T> for DerivedSender alone. is the name, + /// its member access or binding, or the call's expression; an unqualified name is reached through + /// the type it is written in. + /// + private static ITypeSymbol? Receiver(SyntaxNode named, SemanticModel? model, CancellationToken token) + { + if (model is null) return null; + + if (named is SimpleNameSyntax simple) { - foreach (var breach in Breaches(symbols, parameters[i], arguments[i])) - context.Report(Diagnostic.Create(Rule, reported.GetLocation(), arguments[i].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), parameters[i].Name, Owner(method), breach)); + named = simple.Parent switch + { + MemberAccessExpressionSyntax access when access.Name == simple => access, + MemberBindingExpressionSyntax binding => binding, + _ => simple, + }; } + + var receiver = named switch + { + MemberAccessExpressionSyntax access => model.GetTypeInfo(access.Expression, token).Type, + MemberBindingExpressionSyntax binding => binding.Ancestors().OfType().FirstOrDefault(conditional => conditional.WhenNotNull.Span.Contains(binding.Span)) is { } conditional + ? model.GetTypeInfo(conditional.Expression, token).Type + : null, + SimpleNameSyntax => model.GetEnclosingSymbol(named.SpanStart, token)?.ContainingType, + _ => null, + }; + + return receiver is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable ? nullable.TypeArguments[0] : receiver; } - /// The generic as its declaration names it: Decider<TCommand, TEvent>, Runner.Run<T>. + /// + /// The generic as its declaration names it: Decider<TCommand, TEvent>, + /// Runner.Run<T>, an extension block's Ex.extension<T>(T). + /// private static string Owner(ISymbol symbol) => symbol switch { + INamedTypeSymbol { IsExtension: true } extension => $"{extension.ContainingType.Name}.{extension.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)}", INamedTypeSymbol type => type.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), - IMethodSymbol method => $"{method.ContainingType.Name}.{method.Name}<{string.Join(", ", method.OriginalDefinition.TypeParameters.Select(parameter => parameter.Name))}>", + IMethodSymbol method => $"{(method.ContainingType.IsExtension ? method.ContainingType.ContainingType : method.ContainingType).Name}.{method.Name}<{string.Join(", ", method.OriginalDefinition.TypeParameters.Select(parameter => parameter.Name))}>", _ => symbol.Name, }; /// What breaks of what declares, as the message's last clause. - private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol argument) + private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol argument, ITypeSymbol? receiver) { - foreach (var constraint in Effective(symbols, parameter)) + foreach (var constraint in Effective(symbols, parameter, receiver)) { if (argument is ITypeParameterSymbol forwarded) { @@ -174,7 +294,7 @@ private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParamet /// A forwarding type parameter proves a constraint by carrying the attribute, or by a C# constraint that implies it. private static bool Proves(TypeKitSymbols symbols, ITypeParameterSymbol forwarded, TypeArgumentConstraint constraint) => - Effective(symbols, forwarded).Contains(constraint) + Effective(symbols, forwarded, receiver: null).Contains(constraint) || (constraint is TypeArgumentConstraint.Sealed or TypeArgumentConstraint.Concrete && forwarded.HasValueTypeConstraint) || (constraint is TypeArgumentConstraint.Concrete && forwarded.HasConstructorConstraint); @@ -182,34 +302,44 @@ private static bool Proves(TypeKitSymbols symbols, ITypeParameterSymbol forwarde /// The constraints a type parameter declares, with those of the parameter it implements or /// overrides: Sender.Send<T> implementing ISender.Send<[Concrete] T> carries /// [Concrete] whether or not it repeats the attribute, so a call through the class is held - /// to it. Without this, the requirement ended at the interface. + /// to it. Without this, the requirement ended at the interface. A method a base class declares + /// implements an interface for a derived class that lists it, so the type the method is reached + /// through, , is asked too. /// - private static IEnumerable Effective(TypeKitSymbols symbols, ITypeParameterSymbol parameter) + private static IEnumerable Effective(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol? receiver) { var own = symbols.ConstraintsOn(parameter); if (parameter.DeclaringMethod is not { } method) return own; - return own.Concat(Implemented(method.OriginalDefinition) + return own.Concat(Implemented(method.OriginalDefinition, receiver) .Where(implemented => parameter.Ordinal < implemented.OriginalDefinition.TypeParameters.Length) - .SelectMany(implemented => Effective(symbols, implemented.OriginalDefinition.TypeParameters[parameter.Ordinal]))) + .SelectMany(implemented => Effective(symbols, implemented.OriginalDefinition.TypeParameters[parameter.Ordinal], receiver))) .Distinct(); } - /// The methods overrides or implements, explicitly or implicitly, by its declaration. - private static IEnumerable Implemented(IMethodSymbol method) + /// + /// The methods overrides or implements, explicitly or implicitly, by its + /// declaration or for ; a static method implements a static abstract one. + /// + private static IEnumerable Implemented(IMethodSymbol method, ITypeSymbol? receiver) { if (method.OverriddenMethod is { } overridden) yield return overridden; foreach (var explicitly in method.ExplicitInterfaceImplementations) yield return explicitly; - if (method.ExplicitInterfaceImplementations.Length > 0 || method.IsStatic || method.DeclaredAccessibility != Accessibility.Public) yield break; + if (method.ExplicitInterfaceImplementations.Length > 0 || method.DeclaredAccessibility != Accessibility.Public) yield break; - foreach (var contract in method.ContainingType.AllInterfaces) + foreach (var type in SymbolEqualityComparer.Default.Equals(receiver?.OriginalDefinition, method.ContainingType) || receiver is null + ? [method.ContainingType] + : new[] { method.ContainingType, receiver }) { - foreach (var member in contract.GetMembers(method.Name).OfType()) + foreach (var contract in type.AllInterfaces) { - if (member.IsGenericMethod && SymbolEqualityComparer.Default.Equals(method.ContainingType.FindImplementationForInterfaceMember(member)?.OriginalDefinition, method)) - yield return member; + foreach (var member in contract.GetMembers(method.Name).OfType()) + { + if (member.IsGenericMethod && member.IsStatic == method.IsStatic && SymbolEqualityComparer.Default.Equals(type.FindImplementationForInterfaceMember(member)?.OriginalDefinition, method)) + yield return member; + } } } } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs index def223b..8c4aa49 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -308,6 +308,131 @@ public static void Go(Sender sender, Derived derived, IHandler handler, OrderHan Cmtk0010(2, "Handler", "T", "Derived.Go", "is not sealed")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A C# 14 extension block's type parameter is inferred from the receiver at a use: for a method, + /// a property, a static member, a method group and an operator alike, reported at the use. Written, + /// the block's arguments come first in the member's list (handler.Take<Handler, Handler>(handler), + /// measured on Roslyn 5.0.0), and each is reported where it is written. Only the lowered + /// Ex.Go(handler) was reported. + /// + [Fact] + public Task An_extension_block_s_type_parameter_is_checked_at_every_member_use() => + Test( + """ + public static class Ex + { + extension<[Concrete] T>(T value) + { + public void Go() { } + public void Take<[Sealed] TOther>(TOther other) { } + public int Prop => 1; + public static void Make() { } + } + + extension<[Concrete] T>(T value) where T : class + { + public static T operator +(T left, int right) => left; + } + } + + public static class ExtensionUses + { + public static void Go(Handler handler, OrderHandler concrete) + { + {|#0:handler.Go()|}; + _ = {|#1:handler.Prop|}; + {|#2:Handler.Make()|}; + Action group = {|#3:handler.Go|}; + {|#4:Ex.Go(handler)|}; + {|#5:handler.Take(concrete)|}; + handler.Take<{|#6:Handler|}, {|#7:Handler|}>(handler); + _ = {|#8:handler + 1|}; + + concrete.Go(); + _ = concrete.Prop; + OrderHandler.Make(); + Action fine = concrete.Go; + concrete.Take(concrete); + concrete.Take(concrete); + _ = concrete + 1; + } + } + """, + Cmtk0010(0, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(3, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(4, "Handler", "T", "Ex.Go", "is not a concrete class, record or struct"), + Cmtk0010(5, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(6, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct"), + Cmtk0010(7, "Handler", "TOther", "Ex.Take", "is not sealed"), + Cmtk0010(8, "Handler", "T", "Ex.extension(T)", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// A generic attribute's name binds to its constructor, and its type argument was not checked. + [Fact] + public Task A_generic_attribute_s_type_argument_is_checked() => + Test( + """ + [AttributeUsage(AttributeTargets.All)] + public sealed class MarkAttribute<[Concrete] T> : Attribute { } + + [Mark<{|#0:Handler|}>] public sealed class Marked { } + [MarkAttribute<{|#1:Handler|}>] public sealed class MarkedInFull { } + [Mark] public sealed class Fine { } + """, + Cmtk0010(0, "Handler", "T", "MarkAttribute", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "MarkAttribute", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A static method implementing a static abstract interface method, and a base class's method that + /// implements an interface for a derived class, carry the interface's constraint; both were + /// silent. Through the base class itself, which implements nothing, there is no constraint. + /// + [Fact] + public Task A_static_abstract_or_a_base_class_implementation_carries_the_interface_s_constraint() => + Test( + """ + public interface IFactory { static abstract void Make<[Concrete] T>(); } + public sealed class Factory : IFactory { public static void Make() { } } + public class FactoryBase { public static void Make() { } } + public sealed class DerivedFactory : FactoryBase, IFactory { } + + public interface ISender { void Send<[Concrete] T>(T handler); } + public class BaseSender { public void Send(T handler) { } } + public sealed class DerivedSender : BaseSender, ISender + { + public void Inside(Handler handler) => Send<{|#0:Handler|}>(handler); + } + + public static class Calls + { + public static void Go(DerivedSender derived, BaseSender plain, Handler handler, OrderHandler concrete) + { + Factory.Make<{|#1:Handler|}>(); + DerivedFactory.Make<{|#2:Handler|}>(); + Factory.Make(); + derived.Send<{|#3:Handler|}>(handler); + {|#4:derived.Send(handler)|}; + Action group = {|#5:derived.Send|}; + derived.Send(concrete); + plain.Send(handler); + FactoryBase.Make(); + } + + public static void Generic() where TFactory : IFactory => TFactory.Make<{|#6:Handler|}>(); + } + """, + Cmtk0010(0, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "Factory.Make", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "FactoryBase.Make", "is not a concrete class, record or struct"), + Cmtk0010(3, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(4, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(5, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(6, "Handler", "T", "IFactory.Make", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_compilation_without_TypeKit_is_left_alone() => new AnalyzerTest("public sealed class Box { } public static class U { public static Box B; }", referenceTypeKit: false) From a0ddf617ea52077a219dae217580b092f732b82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:27:34 +0200 Subject: [PATCH 064/147] fix: CMTK0010 reports a null-conditional call once, and neither a nameof nor a missing type `holder?.Inst()` was reported at the argument and again at the call, since a member binding with a generic name was not taken for written type arguments. `nameof(Box)` uses no argument of the type and is no longer reported. `Box` added CMTK0010 to the compiler's CS0246; an error type is now skipped. Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Analyzers/README.md | 4 ++- .../TypeArgumentConstraintAnalyzer.cs | 23 ++++++++++-- .../TypeArgumentConstraintAnalyzerTests.cs | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 73bd039..be69327 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -379,7 +379,9 @@ reported at the use, and where the arguments are written, the block's come first its constraint, a static method implementing a `static abstract` one included, and so does a base class's method that implements the interface for a derived class, when it is reached through the derived class. The open generic -inside its own body and an unbound `typeof(Decider<,>)` are never reported. Reflection, +inside its own body, an unbound `typeof(Decider<,>)`, a `nameof(Decider)`, +which uses no argument, and a type argument that does not exist, which is the compiler's error, are +never reported. Reflection, `MakeGenericType`, is out of reach, and the attributes carry no run-time check. ## CMTK0011 diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index 10e4c26..245a4f8 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -93,6 +93,8 @@ private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKi var name = (GenericNameSyntax)context.Node; // names the generic's own parameter, which carries nothing: not a use. if (name.IsUnboundGenericName || name.IsPartOfStructuredTrivia()) return; + // nameof(Box) is the text "Box": no argument of it is used. + if (InNameOf(name, context.SemanticModel, context.CancellationToken)) return; var info = context.SemanticModel.GetSymbolInfo(name, context.CancellationToken); var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); @@ -114,6 +116,19 @@ private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKi Report(context.ReportDiagnostic, symbols, arguments[i], receiver, name.TypeArgumentList.Arguments[i].GetLocation()); } + /// Whether is inside the argument of a nameof, the operator and not a method of that name. + private static bool InNameOf(GenericNameSyntax name, SemanticModel model, CancellationToken token) + { + foreach (var invocation in name.Ancestors().OfType()) + { + if (invocation.Expression is IdentifierNameSyntax { Identifier.ValueText: "nameof" } && invocation.ArgumentList.Span.Contains(name.Span) + && model.GetOperation(invocation, token) is INameOfOperation) + return true; + } + + return false; + } + /// A call whose type arguments are inferred: nothing is written, so it is reported at the call. private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols) { @@ -163,8 +178,9 @@ private static void AnalyzeOperator(OperationAnalysisContext context, TypeKitSym /// private static void AnalyzeInferred(OperationAnalysisContext context, TypeKitSymbols symbols, ISymbol member, SyntaxNode? name, SyntaxNode reported) { - // Written type arguments are a generic name, checked where they are written, the block's included. - if (name is GenericNameSyntax || name is MemberAccessExpressionSyntax { Name: GenericNameSyntax }) return; + // Written type arguments are a generic name, checked where they are written, the block's + // included; holder?.Inst() is a member binding. + if (name is GenericNameSyntax or MemberAccessExpressionSyntax { Name: GenericNameSyntax } or MemberBindingExpressionSyntax { Name: GenericNameSyntax }) return; var arguments = Pairs(member); if (arguments.Count == 0) return; @@ -263,6 +279,9 @@ private static List Zip(ImmutableArray param /// What breaks of what declares, as the message's last clause. private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol argument, ITypeSymbol? receiver) { + // Box is the compiler's error (CS0246); what Missing would be is unknown. + if (argument.TypeKind is TypeKind.Error) yield break; + foreach (var constraint in Effective(symbols, parameter, receiver)) { if (argument is ITypeParameterSymbol forwarded) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs index 8c4aa49..5778bf1 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -433,6 +433,41 @@ public static void Go(DerivedSender derived, BaseSender plain, Handler handler, Cmtk0010(6, "Handler", "T", "IFactory.Make", "is not a concrete class, record or struct")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A null-conditional call with written arguments was reported twice, at the argument and at the + /// call; a nameof names a type and uses no argument of it; a type that does not exist is + /// the compiler's error alone. + /// + [Fact] + public Task A_null_conditional_call_once_and_a_nameof_or_a_missing_type_not_at_all() => + Test( + """ + public sealed class Holder + { + public void Inst<[Sealed] T>() { } + public void Take<[Sealed] T>(T value) { } + } + + public static class Uses + { + public static Box<{|CS0246:Missing|}>? Unknown; + + public static void Go(Holder? holder, Handler handler) + { + holder?.Inst<{|#0:Handler|}>(); + holder?.Inst(); + holder?{|#1:.Take(handler)|}; + _ = nameof(Box); + _ = nameof(Box.ToString); + _ = typeof(Box<{|#2:Handler|}>); + } + } + """, + Cmtk0010(0, "Handler", "T", "Holder.Inst", "is not sealed"), + Cmtk0010(1, "Handler", "T", "Holder.Take", "is not sealed"), + Cmtk0010(2, "Handler", "T", "Box", "is not sealed")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_compilation_without_TypeKit_is_left_alone() => new AnalyzerTest("public sealed class Box { } public static class U { public static Box B; }", referenceTypeKit: false) From 370414a6e06f07af9011fab300458f3fc8af0407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:29:39 +0200 Subject: [PATCH 065/147] fix: CMTK0010 finds a base class's implementation through a type parameter or base. Found by the generation review of the previous commits: a method a base class declares, which implements an interface for a derived class, carried the interface's constraint when reached through the derived class, but not through a type parameter constrained to it (`sender.Send()` with `where TSender : OpenSender`) nor through `base.Send()` inside the derived class. A type parameter now stands for the types it is constrained to, and `base.` for the type it is written in. Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Analyzers/README.md | 2 +- .../TypeArgumentConstraintAnalyzer.cs | 18 +++++++++++++++--- .../TypeArgumentConstraintAnalyzerTests.cs | 9 ++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index be69327..3957ff8 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -378,7 +378,7 @@ reported at the use, and where the arguments are written, the block's come first (`handler.Take(order)`). A method that implements an interface's method carries its constraint, a static method implementing a `static abstract` one included, and so does a base class's method that implements the interface for a derived class, when it is reached through the -derived class. The open generic +derived class, a type parameter constrained to it, or `base.` inside it. The open generic inside its own body, an unbound `typeof(Decider<,>)`, a `nameof(Decider)`, which uses no argument, and a type argument that does not exist, which is the compiler's error, are never reported. Reflection, diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index 245a4f8..efd5b50 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -253,6 +253,8 @@ private static List Zip(ImmutableArray param var receiver = named switch { + // base.Send() runs on this object, whose type is the one written around it. + MemberAccessExpressionSyntax { Expression: BaseExpressionSyntax } => model.GetEnclosingSymbol(named.SpanStart, token)?.ContainingType, MemberAccessExpressionSyntax access => model.GetTypeInfo(access.Expression, token).Type, MemberBindingExpressionSyntax binding => binding.Ancestors().OfType().FirstOrDefault(conditional => conditional.WhenNotNull.Span.Contains(binding.Span)) is { } conditional ? model.GetTypeInfo(conditional.Expression, token).Type @@ -264,6 +266,18 @@ private static List Zip(ImmutableArray param return receiver is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable ? nullable.TypeArguments[0] : receiver; } + /// + /// The types a receiver stands for: itself, or for a type parameter the types it is constrained to, + /// where TSender : OpenSender. A cycle of constraints is the compiler's error, cut off by depth. + /// + private static IEnumerable Through(ITypeSymbol? receiver, int depth) => receiver switch + { + null => [], + ITypeParameterSymbol parameter when depth < 8 => parameter.ConstraintTypes.SelectMany(constraint => Through(constraint, depth + 1)), + ITypeParameterSymbol => [], + _ => [receiver], + }; + /// /// The generic as its declaration names it: Decider<TCommand, TEvent>, /// Runner.Run<T>, an extension block's Ex.extension<T>(T). @@ -348,9 +362,7 @@ private static IEnumerable Implemented(IMethodSymbol method, ITyp if (method.ExplicitInterfaceImplementations.Length > 0 || method.DeclaredAccessibility != Accessibility.Public) yield break; - foreach (var type in SymbolEqualityComparer.Default.Equals(receiver?.OriginalDefinition, method.ContainingType) || receiver is null - ? [method.ContainingType] - : new[] { method.ContainingType, receiver }) + foreach (var type in Through(receiver, depth: 0).Prepend(method.ContainingType).Distinct(SymbolEqualityComparer.Default)) { foreach (var contract in type.AllInterfaces) { diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs index 5778bf1..02a5c61 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -404,8 +404,11 @@ public class BaseSender { public void Send(T handler) { } } public sealed class DerivedSender : BaseSender, ISender { public void Inside(Handler handler) => Send<{|#0:Handler|}>(handler); + public void Based(Handler handler) => base.Send<{|#8:Handler|}>(handler); } + public class OpenSender : BaseSender, ISender { } + public static class Calls { public static void Go(DerivedSender derived, BaseSender plain, Handler handler, OrderHandler concrete) @@ -422,6 +425,8 @@ public static void Go(DerivedSender derived, BaseSender plain, Handler handler, } public static void Generic() where TFactory : IFactory => TFactory.Make<{|#6:Handler|}>(); + + public static void Constrained(TSender sender, Handler handler) where TSender : OpenSender => sender.Send<{|#7:Handler|}>(handler); } """, Cmtk0010(0, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), @@ -430,7 +435,9 @@ public static void Go(DerivedSender derived, BaseSender plain, Handler handler, Cmtk0010(3, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), Cmtk0010(4, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), Cmtk0010(5, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), - Cmtk0010(6, "Handler", "T", "IFactory.Make", "is not a concrete class, record or struct")) + Cmtk0010(6, "Handler", "T", "IFactory.Make", "is not a concrete class, record or struct"), + Cmtk0010(7, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct"), + Cmtk0010(8, "Handler", "T", "BaseSender.Send", "is not a concrete class, record or struct")) .RunAsync(TestContext.Current.CancellationToken); /// From 579dc3634d053417e42ce835e78b6347117a65b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:31:04 +0200 Subject: [PATCH 066/147] docs: the analyzer README says the two packages move together, and states its reasons in sentences Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Analyzers/README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 3957ff8..01a4b64 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -3,7 +3,7 @@ The Roslyn analyzers for [CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit). You do not reference this package yourself: `CodoMetis.TypeKit` depends on it, so every project that can see `Option`, `Result` or the value-object contracts gets the rules, directly or through -another project. Reference it directly only to take a newer analyzer than the base package you are on. +another project. The two share a version and move together. The characteristic failure these rules prevent is code that compiles and does less than it appears to: an instance that passed no factory, or a value object that was never generated. @@ -14,7 +14,7 @@ appears to: an instance that passed no factory, or a value object that was never |---|---|---| | CMTK0001 | Error | `default`, `default(T)`, `new T()`, `new()` and `new T { }` of a value object, an `Option`, a `Result`, or a struct marked `[RequireCustomInitialization]`. Also through a type parameter whose constraints make it one of those. A default that is only compared (`id == default`, `id.Equals(default)`, `ThrowIfEqual(id, default)`, `Assert.NotEqual(default, id)`) is a guard and is not reported, nor is one assigned to an `out` parameter of a Try method that returns `bool`. | | CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | -| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. The drops: a statement, awaited or not; a conversion of its `Task` to a plain `Task`, a method group's included; `Wait`; a loop that never reads it; a branch of a conditional, a `switch` expression or a `??` that picked the call. `_` says the drop is meant. | +| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. The drops: a statement, awaited or not; its `Task` converted to a plain `Task`, also where a method group is the conversion; `Wait`; a loop that never reads it; a branch of a conditional, a `switch` expression or a `??` that picked the call. `_` says the drop is meant. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core satellite. | | CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero is not reported. | | CMTK0006 | Warning | A field or auto-property of such a type in a class that no initializer, `required` or constructor sets, or a `required` one that a `[SetsRequiredMembers]` constructor does not set. | @@ -50,9 +50,9 @@ Result pending = Result.Error(OrderFault.Empty); ``` Code inside the type itself is exempt, since its factories have to construct it. A `Nullable` -of a value object is a null, not an instance, and is not reported. What the rule cannot see, such -as an array created with a length or an unassigned field of a class, is why an uninitialized -`Result` throws instead of reporting an error it never had. +of a value object is a null, not an instance, and is not reported. An array created with a length +or an unassigned field of a class can still hold a `default` the rule cannot see, so an +uninitialized `Result` throws rather than report an error it never had. A default that is only compared is a guard against exactly those, or an assertion that one is not there, and is not reported: @@ -224,7 +224,8 @@ OrderId[] kept = [.. rows.Select(r => OrderId.From(r.Id))]; // built from valu `stackalloc`, `GC.AllocateUninitializedArray`, `GC.AllocateArray` and `Array.Resize` are reported too; `Array.Resize` adds default slots whenever the new size is larger, which the rule cannot tell. A constant length or size of zero creates no slot, as `new OrderId[0]` does not, and is not reported. -A warning, since filling such an array in a loop straight after is correct and indistinguishable. +A warning, since an array filled in a loop straight after it is created is correct, and the rule +cannot tell it from one that is not. ## CMTK0006 @@ -443,7 +444,8 @@ The errors are the rules where the code is wrong whatever surrounds it: a `defau argument that breaks what its parameter declares (CMTK0010), a constructor a host will look for and not find (CMTK0011), and branches bound to members by their order (CMTK0012). So is a result dropped without saying so (CMTK0003): a drop that nothing depends on is correct, and `_` says it in -the code, where every reader sees it. A warning is what gets ignored, so these fail the build. The +the code, where every reader sees it. These fail the build because a warning on correct code is +rare for them and a missed one is a defect at run time. The others are warnings, or for CMTK0007 Info, because correct code can look the same and has nothing but a suppression to say so: an array filled in a loop straight after it is created (CMTK0005), a member set in a second step, such as a component's `OnInitialized` (CMTK0006), a sequence known not From 51bfc20137436d429e282befd56c7995c77f7717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:31:38 +0200 Subject: [PATCH 067/147] docs: the generators README names both validation-free paths and states its closed-set rules in sentences that parse Co-Authored-By: Claude Opus 5.5 --- docs/closed-sets.md | 4 +-- src/CodoMetis.TypeKit.Generators/README.md | 34 ++++++++++++---------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/closed-sets.md b/docs/closed-sets.md index a01aff6..cc12e66 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -167,11 +167,11 @@ A declaration they cannot complete fails the build, naming it; the generators' | is not `sealed`: a set with subtypes is not closed | CMTK1014 | | declares no member | CMTK1015 | | has a constructor that is not private | CMTK1016 | -| has another static member of its own type: a property, a field that is not `readonly`, or a field that is not public, an instance that is no member | CMTK1017 | +| has another static member of its own type: a property, a field that is not `readonly`, or a field that is not public, which would hold an instance that is no member | CMTK1017 | | is generic, or nested in a generic type | CMTK1018 | | has a member not created with `new` where it is declared: `Legacy = Open` would give one instance two names | CMTK1019 | | is a record: `with` would copy a member into an instance that is none | CMTK1020 | -| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), one name to `Match`'s parameters and to `TryParse(name, ignoreCase: true)` | CMTK1021 | +| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), which collide in `Match`'s parameters and in `TryParse(name, ignoreCase: true)` | CMTK1021 | | writes a generated member by hand, `ToString()` apart | CMTK1011 | | is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index ec1bde4..25e45ac 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -88,8 +88,8 @@ Any non-generic class, struct or enum, with the exceptions CMTK1005 lists. The g follow what the wrapped type offers: parsing where it can be parsed, formatting where it formats, and comparison where it is comparable. Numbers, `bool`, `char`, `string`, `Guid`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` and enums have all of it; a `Uri` has no ordering, and neither -do NodaTime's `OffsetDateTime`, `ZonedDateTime`, `Period` and `Interval`, whose `Interval` has no type -converter to parse through either. An enum parses as `Enum.TryParse` does, case-sensitively: a name, a +do NodaTime's `OffsetDateTime`, `ZonedDateTime`, `Period` and `Interval`, and `Interval` also has no +type converter to parse through. An enum parses as `Enum.TryParse` does, case-sensitively: a name, a comma-separated list of names, or a number, which need not name a member. A generic wrapped type, a tuple included, is refused (CMTK1005): a `List` or an `ImmutableArray` @@ -115,10 +115,12 @@ does. | `{TSelf}Extensions.GetValue()` and `ValueOrNull()` | every value object an extension class can reach (not a `private` or `protected` nested one) | For EF Core queries, where they translate to the column. | | `IValueObjectMaterializer` | every value object | Explicit, invisible on the type. Rebuilds an instance without validation, for the EF Core satellite. | -Every generated way into a validated value object goes through `Create`. The only exception is the -explicit materializer, for values the application wrote itself. Inside the type, your own members can -reach the private constructor, which applies no rules, because `Create` needs it: call it from -`Create` only. Another way in is a static method that calls `From` or `Create`. +Every generated way into a validated value object goes through `Create`. Two paths skip it, both for +values the application wrote itself: the explicit materializer, which the EF Core satellite reads a +column through, and `StoredJsonConverterFactory`, registered only on the options that read the +application's own JSON. Inside the type, your own members can reach the private constructor, which +applies no rules, because `Create` needs it: call it from `Create` only. For another way in, write a +static method that calls `From` or `Create`. `default(Email)` is not one of those ways, and the analyzer refuses it (CMTK0001), but an array slot or a JSON document without the property can still produce it (CMTK0005, and "A missing property" below). @@ -135,9 +137,9 @@ interfaces the generators implement, and `IConvertible` where they generate it. implementation of a member of an interface they implement, such as `IParsable.Parse` or `IFormattable.ToString`: every caller through the interface would reach it rather than the generated member, and a generic `T.Parse` would skip `Create`. `Create` is declared once, `public static`, and -never as an explicit interface implementation: alone, the generated code cannot call it, and beside a -public one, generic code calling `T.Create` would reach its rules instead of the ones every generated -way in applies. +never as an explicit interface implementation. The generated code cannot call an explicit one; and +beside a public one, generic code calling `T.Create` would reach the explicit one's rules, not those +every generated way in applies. To change what a value object prints, declare `ToString()`: a record's `PrintMembers(StringBuilder)` is CMTK1011 without it, since the generated `ToString()` prints the wrapped value and never calls it. @@ -245,12 +247,12 @@ and the ASP.NET Core satellite answers it by kind. Each member is initialized with `new` where it is declared (CMTK1019), and the constructors are private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated -member, nor differ from another's only in case (`Open` and `open`), one name to -`TryParse(name, ignoreCase: true)`, CMTK1021. A `ToString()` -written by hand is kept, and the name is still what JSON, parsing and the column use; so is an overload of a generated method with -another signature, such as `Parse(int rank)`. Any other generated member written by hand is CMTK1011. An instance the set's -own code creates beside its members has no name, so reading its name throws rather than write a -null. A JSON `null` reads as `null`, as for any class, unless the serializer's +member, nor differ from another's only in case (`Open` and `open`), since +`TryParse(name, ignoreCase: true)` could not tell them apart (CMTK1021). A `ToString()` written by +hand is kept, and the name is still what JSON, parsing and the column use. An overload of a generated +method with another signature, such as `Parse(int rank)`, is kept too. Any other generated member +written by hand is CMTK1011. An instance the set's own code creates beside its members has no name, +so reading its name throws rather than writing a null. A JSON `null` reads as `null`, as for any class, unless the serializer's `RespectNullableAnnotations` refuses it. ## JSON @@ -354,7 +356,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1018 | A closed set that is generic or nested in a generic type. | | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | -| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose name differs from another member's only in case: one name to `TryParse(name, ignoreCase: true)`, and to `Match`'s parameters where the first letter is the difference. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose name differs from another member's only in case: they collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference. | | CMTK1022 | An abstract type with a value-object marker: it would get no member, and a value held as it, of a type derived from it, would have no `Value` and be written as `{}`. Declare the marker on the sealed type. An abstract closed set is CMTK1014. | The analyzers that come with the base package add CMTK0001 to CMTK0012; see From e170af9c22372ee2640d1f3d31839f67b2df0920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:32:41 +0200 Subject: [PATCH 068/147] docs: decisions 53 to 55, a fault made by Fault.Of alone, an abstract value object refused, a component name claimed twice refused Co-Authored-By: Claude Opus 5.5 --- .../adr/0053-fault-made-by-fault-of-alone.md | 18 ++++++++++++++++ .../adr/0054-abstract-value-object-refused.md | 15 +++++++++++++ ...55-component-name-claimed-twice-refused.md | 21 +++++++++++++++++++ design/adr/README.md | 3 +++ design/aspects.md | 3 ++- design/openapi.md | 4 +++- design/option-result.md | 5 +++++ 7 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 design/adr/0053-fault-made-by-fault-of-alone.md create mode 100644 design/adr/0054-abstract-value-object-refused.md create mode 100644 design/adr/0055-component-name-claimed-twice-refused.md diff --git a/design/adr/0053-fault-made-by-fault-of-alone.md b/design/adr/0053-fault-made-by-fault-of-alone.md new file mode 100644 index 0000000..6608f04 --- /dev/null +++ b/design/adr/0053-fault-made-by-fault-of-alone.md @@ -0,0 +1,18 @@ +# 0053. A `Fault` is made by `Fault.Of` alone + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [Option and Result](../option-result.md). + +`Fault` had an implicit conversion from its reason, and the first review's fix refused +`fault == reason`, `!=` and `Equals(TFault)`. The conversion still reached a comparison everywhere +else: `Fault? f == F.NotFound`, `List>.Contains(F.NotFound)`, a `HashSet`, +`EqualityComparer>.Default.Equals(f, F.NotFound)`, and Testing's +`ShouldBeError(F.NotFound)`, each compiling and false whenever the fault carried a detail (measured). +The conversion is gone; `Fault.Of(reason)` and `Fault.Of(reason, detail)` make a fault. The refused +operators and `Equals(TFault)` stay, since without them `==` is CS0019, which does not say to +compare `Reason`, and `fault.Equals(reason)` binds to `Equals(object)` and is always false. +`Array.IndexOf(array, reason)` still compiles, through `Array.IndexOf(Array, object)`, as +`object.Equals` does; nothing in the type can refuse it. `Fault` is new in 2.0.0, so this +breaks nothing that shipped. diff --git a/design/adr/0054-abstract-value-object-refused.md b/design/adr/0054-abstract-value-object-refused.md new file mode 100644 index 0000000..a40079d --- /dev/null +++ b/design/adr/0054-abstract-value-object-refused.md @@ -0,0 +1,15 @@ +# 0054. An abstract type with a value-object marker is refused (CMTK1022) + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [Aspects](../aspects.md). + +The fabric selected only non-abstract types, so `abstract partial record A : IValue;` +compiled with no member and no diagnostic, and a value held as `A`, of a sealed type derived from +it, was written as `{}` (measured). That is the repository's characteristic failure, a type that +compiles without being generated. CMTK1022 refuses it at Error, reported by the fabric on the +abstract type. A new Error ships only in a major version +([decision 34](0034-errors-fixed-within-major-version-warnings.md)), and 2.0.0 is one; a type that +built against 1.5.1 with such a declaration generated nothing then either. An abstract closed set +was already CMTK1014 (not sealed). A sealed type derived from an abstract one is still generated. diff --git a/design/adr/0055-component-name-claimed-twice-refused.md b/design/adr/0055-component-name-claimed-twice-refused.md new file mode 100644 index 0000000..7c7ab60 --- /dev/null +++ b/design/adr/0055-component-name-claimed-twice-refused.md @@ -0,0 +1,21 @@ +# 0055. A component name two types claim is refused; a qualified fault name follows the nesting chain + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [OpenAPI](../openapi.md). + +[Decision 24](0024-nested-value-objects-named-after-nesting.md) names a nested type's component after +its nesting chain, `Order.Id` as `OrderId`. A nested closed set `Order.Status` beside a top-level +`OrderStatus` then got one component, which described whichever ASP.NET met first, and the property +typed `Order.Status` referenced the other's names (measured, on the JIT and under Native AOT). CMTK1007 +catches the value-object case within one compilation only, through the companion classes; a closed +set has none, and two assemblies never meet at compile time. Decision 24's names stay, since value +objects' component names shipped in 1.x. Generating a document in which a name this package gave is +also another type's throws an `InvalidOperationException` naming both, per document. Two types this +package does not rename stay ASP.NET's business. + +For the same reason, `FaultResponseOptions.QualifiedFaultNames` names an enum after its nesting +chain with dots, `Billing.Fault.NotFound`, as a closed set's `QualifiedName` does; it had used the +simple name, so `Billing.Fault` and `Shipping.Fault` sent one value. The fault responses are new in +2.0.0. diff --git a/design/adr/README.md b/design/adr/README.md index d989929..edaf748 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -59,3 +59,6 @@ per area ([design](../plan.md)) link to the records they rest on. | [0050](0050-warnings-cmtk0003-alone-becomes-error-2.md) | Of the Warnings, CMTK0003 alone becomes an Error in 2.0.0 | 2026-10-13 | accepted | | [0051](0051-closed-set-parses-ignoring-case-only.md) | A closed set parses ignoring case only when asked, and refuses members that differ only in case | 2026-10-13 | accepted | | [0052](0052-cause-on-fault-joins-2-1.md) | A `Cause` on `Fault` joins 2.1 | 2026-10-13 | accepted | +| [0053](0053-fault-made-by-fault-of-alone.md) | A `Fault` is made by `Fault.Of` alone | 2026-10-13 | accepted | +| [0054](0054-abstract-value-object-refused.md) | An abstract type with a value-object marker is refused (CMTK1022) | 2026-10-13 | accepted | +| [0055](0055-component-name-claimed-twice-refused.md) | A component name two types claim is refused; a qualified fault name follows the nesting chain | 2026-10-13 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 497bbb8..c303acc 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -18,7 +18,8 @@ any other, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares equal only to its own type, which is not value equality), CMTK1007 (the name of the `GetValue`/`ValueOrNull` class is taken, see below), CMTK1008 (a comparison member beside the - seam, see below), CMTK1009 (a hand-written constructor, see below). + seam, see below), CMTK1009 (a hand-written constructor, see below), CMTK1022 (an abstract type, + reported by the fabric, [decision 54](adr/0054-abstract-value-object-refused.md)). - **No hand-written constructor (decided 2026-09-27).** A value object's one constructor is the generated private one, and a hand-written instance constructor is CMTK1009. The implementation aspect answers it before introducing its own, since it reads only its target: `Constructors` minus diff --git a/design/openapi.md b/design/openapi.md index f1bae5f..6ed16ff 100644 --- a/design/openapi.md +++ b/design/openapi.md @@ -34,7 +34,9 @@ position, and the change lands in the hoisted component. simple name, so `Shop.Id` and `Stock.Id` shared one, and a stock id wrapping an `int` was documented as a uuid. `AddTypeKit()` wraps `CreateSchemaReferenceId`: a nested value object that got ASP.NET's default name is named after its nesting chain (`ShopId`, as its companion class is `ShopIdExtensions`); - a name the host's own delegate chose, or `null` for an inlined value object, is kept. + a name the host's own delegate chose, or `null` for an inlined value object, is kept. A name that + is also another type's, `Order.Status` beside a top-level `OrderStatus`, refuses the document + ([decision 55](adr/0055-component-name-claimed-twice-refused.md)). - **Known gaps, documented rather than fixed:** a validation attribute on a value-object property lands in the shared component, as ASP.NET does for any referenced schema (rules belong in `Create`); a property absent from a request body stays `default`, since System.Text.Json calls no converter diff --git a/design/option-result.md b/design/option-result.md index 95666d6..e9e730d 100644 --- a/design/option-result.md +++ b/design/option-result.md @@ -49,6 +49,11 @@ is the value-less shape. Anything that turns this into a second LanguageExt is o where it is what someone stepping through wants to see. The debugger is not a log. - **`Result` has no `AsEnumerable`.** A sequence of zero or one units says no more than the `bool` conversion. +- **`Fault` (2.0.0)** carries a reason from a fixed set and an optional sentence about one + occurrence. It is made by `Fault.Of` alone, and comparing it with a bare reason is refused, since a + fault with a detail never equals one + ([decision 53](adr/0053-fault-made-by-fault-of-alone.md)); `Reason` is what a caller compares. + A `Cause` beside them joins 2.1 ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)). - **Pipelines (decided 2026-09-28).** `MapAsync`/`BindAsync` take an asynchronous selector on a result, and every combinator of both shapes continues a `Task>` (`MapAsync`, `BindAsync`, `MapErrorAsync`, `TapAsync`), so a chain is awaited once, at its end; a completeness test holds From 2f15ff01da4ed0851c59ff680ab8731ed7439a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:36:08 +0200 Subject: [PATCH 069/147] docs: lead with one set of rules carried from the request to the database, in the README and the package descriptions Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++++++++-- .../CodoMetis.TypeKit.Generators.csproj | 2 +- src/CodoMetis.TypeKit.Generators/README.md | 5 +++-- src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj | 2 +- src/CodoMetis.TypeKit/README.md | 9 ++++++--- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 05ad63e..9b919ae 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ # CodoMetis.TypeKit -Strong types for .NET 10: `Option` and `Result` types that cannot be misused, and value objects and -closed sets that are written as one line and generated at compile time. +Strong types for .NET 10, with one set of rules from the request to the database. Value objects and +closed sets (smart enums) are written as one line and generated at compile time. A value object's one +`Create` is what JSON, route binding, parsing and `TryFrom` apply, and the fault it returns reaches an +HTTP problem response as a typed `Result`, with nothing to translate between libraries on the way. +EF Core and the OpenAPI document see the type it wraps. `Option` and `Result` cannot be misused: no +`.Value`, no `default` in source, no ignored result. [![.NET](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/actions/workflows/dotnet.yml/badge.svg)](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/actions/workflows/dotnet.yml) [![NuGet](https://img.shields.io/nuget/v/CodoMetis.TypeKit.svg)](https://www.nuget.org/packages/CodoMetis.TypeKit) @@ -184,6 +188,9 @@ compiled and its stated outputs are tested, as the READMEs' are. Good libraries exist for both halves of TypeKit. TypeKit fits when: +- You want the value objects, the outcomes and the HTTP and database adapters to share one model: + the fault `Email.Create` returns is the error of a `Result` pipeline and then the problem response, + without mapping between a validation library, a result library and an adapter. - You build on .NET 10 with System.Text.Json, EF Core and ASP.NET Core, and want value objects that change no contract: the JSON, the column and the OpenAPI schema stay the wrapped type's, with nothing registered per type, and `.Value` works inside a LINQ query. diff --git a/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj b/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj index 0c9787f..7ea4d01 100644 --- a/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj +++ b/src/CodoMetis.TypeKit.Generators/CodoMetis.TypeKit.Generators.csproj @@ -6,7 +6,7 @@ Metalama.Framework reference flows on purpose (spikes/FabricSpike). --> - Compile-time generation of strongly-typed value objects and closed sets (smart enums) for CodoMetis.TypeKit, built on Metalama. + Compile-time generation of value objects and closed sets (smart enums) for CodoMetis.TypeKit, built on Metalama: one declared Create is what JSON, parsing, route binding and TryFrom apply. value-object;strongly-typed-id;primitive-obsession;code-generation;metalama;ddd;domain-driven-design;system-text-json;iparsable;validation diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 25e45ac..cda4e02 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -1,9 +1,10 @@ # CodoMetis.TypeKit.Generators -Compile-time generation of strongly-typed value objects for +Compile-time generation of value objects and closed sets (smart enums) for [CodoMetis.TypeKit](https://www.nuget.org/packages/CodoMetis.TypeKit), built on [Metalama](https://www.postsharp.net/metalama). One line becomes a complete value object, with -its JSON converter, parsing, formatting, comparison, type converter and query companions. +its JSON converter, parsing, formatting, comparison, type converter and query companions, each +applying the one `Create` you wrote. ```bash dotnet add package CodoMetis.TypeKit.Generators diff --git a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj index 498ca87..f306981 100644 --- a/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj +++ b/src/CodoMetis.TypeKit/CodoMetis.TypeKit.csproj @@ -5,7 +5,7 @@ Metalama, directly or transitively; MetalamaBoundaryTests fails the build if it does. --> - Option and Result types that cannot be misused, the contracts for strongly-typed value objects and closed sets (smart enums), a merge patch's field, and the text guards. + Option and Result types that cannot be misused, and the contracts for value objects and closed sets (smart enums): one validation rule and one typed fault, carried through JSON, EF Core, OpenAPI and HTTP problem details by the TypeKit packages. Also a merge patch's field and the text guards. option;result;maybe;railway-oriented-programming;functional;error-handling;value-object;strongly-typed-id;primitive-obsession;ddd;domain-driven-design;native-aot diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 18981db..0d91787 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -1,8 +1,11 @@ # CodoMetis.TypeKit -`Option` and `Result` that cannot be misused, and the contracts for strongly-typed -value objects. This is the base package: it brings no code generator and no Metalama, only the -types, and the analyzers that keep them honest. +`Option` and `Result` that cannot be misused, and the contracts that value objects and +closed sets (smart enums) are generated from, so validation, outcomes and the EF Core and ASP.NET Core +packages share one model: a value object's `Create` answers a `Result`, and its fault is the error the +rest of the application handles. Also `Patch`, a merge patch's field, and the text guards. This is +the base package: it brings no code generator and no Metalama, only the types, and the analyzers that +keep them honest. ```bash dotnet add package CodoMetis.TypeKit From 63471e79edb35f78f5f4adb86563a11139e90620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:31:07 +0200 Subject: [PATCH 070/147] fix: .Value translates only on a column TypeKit's converter stores, and is refused on any other The member and method translators re-typed any value-object operand as its wrapped type without looking at its converter. A property the application converts itself does not hold .Value: with HasConversion(c => "C:" + c.Value, ...), t.Code.Value == "abc" compared the column with 'abc' and found no row; with a quantity stored as text, t.Quantity.Value > 10 compared text with a number and found the row holding 7. The same with HasConversion() and a ConfigureConventions conversion. The translation now happens only where the operand's converter is TypeKit's: its conversion to the provider calls ValueObjectConverter<,>.ProviderValue on the value object and nothing else, before the wrapped type's own conversion where it has one (an enum's). It is read from the expression, so it holds for the converter UseTypeKit() composes, for ValueObjectConverter<,> named in HasConversion, and for the converters a compiled model creates again from the C# it wrote. Anywhere else the translator returns null and EF refuses the query. An operand not mapped yet, such as a parameter, is converted as before. ForeignConverterTests covers the three registration forms on SQLite, the explicit ValueObjectConverter<,>, an enum's composed converter, and a compiled model generated and compiled in the test (DesignTime, shared with MigrationSnapshotTests). Co-Authored-By: Claude Opus 5.5 --- docs/ef-core.md | 3 +- .../README.md | 7 + .../ValueObjectMapping.cs | 64 ++++++ .../ValueObjectTranslators.cs | 26 ++- .../DesignTime.cs | 74 +++++++ .../ForeignConverterTests.cs | 208 ++++++++++++++++++ .../MigrationSnapshotTests.cs | 30 +-- 7 files changed, 379 insertions(+), 33 deletions(-) create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/DesignTime.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ForeignConverterTests.cs diff --git a/docs/ef-core.md b/docs/ef-core.md index 0a0f2e9..6dd65d0 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -141,7 +141,8 @@ db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g A table's column is re-typed, not cast, so an index on it still serves the query. The element of a primitive collection, a property in a JSON column and a parameter are converted, which may add a -cast. Ordering in SQL follows the column's collation, while the generated comparison in memory is +cast. A property with a converter of the application's own does not hold `.Value`, so `.Value` on it +is refused ("could not be translated"); comparing the value object itself still works. Ordering in SQL follows the column's collation, while the generated comparison in memory is ordinal for a string, as for any string column. ## Reading back without validation diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 02f716a..0e2d89a 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -122,6 +122,13 @@ re-typed, not cast, so an index on it still serves the query. The element of a p converted, which works everywhere and may add a cast. `ValueOrNull()` is for an optional value object, where `.Value` would first unwrap the `Nullable`. +They translate only where the column holds the wrapped value, which is where TypeKit's converter +stores it: the one `UseTypeKit()` applies, or `ValueObjectConverter<,>` named in `HasConversion`. A +property the application converts itself (`HasConversion(c => "C:" + c.Value, …)`, a converter type, +a conversion in `ConfigureConventions`) does not hold `.Value`, so a query that reads it there is +refused with EF's "could not be translated", rather than compared with what the column holds. +Comparing the value object itself (`o.Code == code`) goes through that converter and still works. + Ordering in SQL follows the database's collation for the column, while the generated comparison in memory is ordinal for a string, as it is for any string column. diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs index 5918ce7..6bf1531 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectMapping.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; using CodoMetis.TypeKit.CompilerServices; using CodoMetis.TypeKit.ValueObjects; +using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.Json; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -43,6 +46,21 @@ public static ValueObjectMapping For(GeneratedValueObjectAttribute valueObject) /// The value object's reader/writer over the wrapped type's, or where the wrapped type has none. protected abstract JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? wrapped); + /// + /// Whether a column converted by holds the wrapped value, as the + /// wrapped type's own mapping (whose converter is ) stores it: + /// whether the converter is this value object's , + /// alone or composed onto the wrapped type's. + /// + /// + /// Read from the conversion expression, not the converter's type: UseTypeKit() composes a plain + /// , a compiled model creates its converters again from + /// the expressions it wrote out, and an application can name + /// itself. Every one of them calls + /// on the value object and nothing else, before the wrapped type's own conversion, if it has one. + /// + public abstract bool StoresTheWrappedValue(ValueConverter converter, ValueConverter? wrappedConverter); + public RelationalTypeMapping ComposeOnto(RelationalTypeMapping wrapped) => (RelationalTypeMapping)wrapped.WithComposedConverter(Converter, jsonValueReaderWriter: ReaderWriterOver(wrapped.JsonValueReaderWriter)); @@ -53,6 +71,52 @@ private sealed class Of : ValueObjectMapping public override ValueConverter Converter { get; } = new ValueConverter(ValueObjectConverter.ToProvider, ValueObjectConverter.FromProvider); + private static readonly MethodInfo ProviderValue = ((MethodCallExpression)ValueObjectConverter.ToProvider.Body).Method; + + public override bool StoresTheWrappedValue(ValueConverter converter, ValueConverter? wrappedConverter) + { + if (converter.ConvertToProviderExpression is not { Parameters: [var instance] } toProvider || instance.Type != typeof(TValueObject)) return false; + + // The conversion with ProviderValue(instance) taken out is what remains to be applied to the + // wrapped value: nothing, or exactly the wrapped type's own conversion. + var wrapped = Expression.Parameter(typeof(T), "value"); + var unwrapped = new ProviderValueReplacer(instance, wrapped); + var remaining = unwrapped.Visit(toProvider.Body); + + if (unwrapped.Replaced != 1 || unwrapped.InstanceLeft) return false; + + var expected = wrappedConverter?.ConvertToProviderExpression is { Parameters: [var value] } wrappedToProvider + ? ReplacingExpressionVisitor.Replace(value, wrapped, wrappedToProvider.Body) + : wrapped; + + return ExpressionEqualityComparer.Instance.Equals(remaining, expected); + } + + /// Replaces ProviderValue(instance) with the wrapped value, and notes any other use of the instance. + private sealed class ProviderValueReplacer(ParameterExpression instance, ParameterExpression wrapped) : ExpressionVisitor + { + public int Replaced { get; private set; } + + public bool InstanceLeft { get; private set; } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Object is null && node.Method == ProviderValue && node.Arguments is [var argument] && argument == instance) + { + Replaced++; + return wrapped; + } + + return base.VisitMethodCall(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (node == instance) InstanceLeft = true; + return node; + } + } + // The wrapped mapping's reader/writer handles values of the wrapped type, converted or not, so // it is a JsonValueReaderWriter; anything else is left to EF's own composition. protected override JsonValueReaderWriter? ReaderWriterOver(JsonValueReaderWriter? wrapped) => diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs index 83e54d4..4cbf797 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs @@ -35,9 +35,9 @@ private sealed class Translator(ISqlExpressionFactory sqlExpressionFactory, IRel ) { if (instance is null || member.Name != nameof(IValueObject<,>.Value)) return null; - if (member.DeclaringType is not { } declaringType || !ValueObjectTypes.IsValueObject(declaringType)) return null; + if (member.DeclaringType is not { } declaringType || ValueObjectTypes.Describe(declaringType) is not { } valueObject) return null; - return WrappedValue.Of(instance, returnType, sqlExpressionFactory, typeMappingSource); + return WrappedValue.Of(instance, valueObject, returnType, sqlExpressionFactory, typeMappingSource); } } } @@ -82,7 +82,7 @@ private sealed class Translator(ISqlExpressionFactory sqlExpressionFactory, IRel || valueObject.WrappedType != valueType) return null; - return WrappedValue.Of(arguments[0], valueType, sqlExpressionFactory, typeMappingSource); + return WrappedValue.Of(arguments[0], valueObject, valueType, sqlExpressionFactory, typeMappingSource); } } } @@ -97,11 +97,29 @@ internal static class WrappedValue /// parameter or a function result, is converted, and so is the element column of a primitive /// collection (). /// - public static SqlExpression Of(SqlExpression operand, Type valueType, ISqlExpressionFactory sqlExpressionFactory, IRelationalTypeMappingSource typeMappingSource) + /// + /// , so EF refuses the query, where the operand's converter is not TypeKit's: + /// a column the application converts itself (HasConversion(c => "C:" + c.Value, …), a converter + /// type, a ConfigureConventions conversion) does not hold .Value, and re-typed as if it + /// did, t.Code.Value == "abc" compared the column with what it never holds and answered no row. + /// An operand that is not mapped yet, such as a parameter whose mapping EF infers later, is a value + /// object of the query's own and is converted as before. + /// + public static SqlExpression? Of( + SqlExpression operand, + GeneratedValueObjectAttribute valueObject, + Type valueType, + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource + ) { var mapping = (operand.TypeMapping?.StoreType is { } storeType ? typeMappingSource.FindMapping(valueType, storeType) : null) ?? typeMappingSource.FindMapping(valueType); + if (operand.TypeMapping is { } operandMapping + && (operandMapping.Converter is not { } converter || !ValueObjectMapping.For(valueObject).StoresTheWrappedValue(converter, mapping?.Converter))) + return null; + return operand is ColumnExpression column && !IsCollectionElement(column) ? new ColumnExpression(column.Name, column.TableAlias, valueType, mapping, column.IsNullable) : sqlExpressionFactory.Convert(operand, valueType, mapping); diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/DesignTime.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/DesignTime.cs new file mode 100644 index 0000000..704da53 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/DesignTime.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// What dotnet ef does at design time, in process: its services, and the code it writes compiled back. +internal static class DesignTime +{ + /// + /// The design-time services for a context, in the order and with the lookup of EF's + /// DesignTimeServicesBuilder: the context, the provider's services (named by the provider assembly's + /// attribute), then EF's own, which are only added where missing. + /// + public static ServiceProvider Services(DbContext db) + { + var services = new ServiceCollection().AddDbContextDesignTimeServices(db); + var provider = Assembly.Load(new AssemblyName(db.GetService().Name)); + var providerServices = provider.GetCustomAttribute().ShouldNotBeNull($"{provider} names no design-time services."); + ((IDesignTimeServices)Activator.CreateInstance(provider.GetType(providerServices.TypeName, throwOnError: true)!)!).ConfigureDesignTimeServices(services); + services.AddEntityFrameworkDesignTimeServices(); + + return services.BuildServiceProvider(); + } + + /// The context's compiled model, as dotnet ef dbcontext optimize writes it, compiled and loaded. + public static IModel CompiledModel(DbContext db) + { + using var designTime = Services(db); + var options = new CompiledModelCodeGenerationOptions + { + ContextType = db.GetType(), + ModelNamespace = $"CompiledModel{Guid.NewGuid():N}", + Language = "C#", + UseNullableReferenceTypes = true, + }; + var files = designTime.GetRequiredService().Select(options) + .GenerateModel(db.GetService().Model, options); + + var instance = Compile([.. files.Select(file => file.Code)], db.GetType().Assembly) + .GetTypes() + .Single(type => type.Name == $"{db.GetType().Name}Model") + .GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)!; + + return (IModel)instance.GetValue(null)!; + } + + public static Assembly Compile(IReadOnlyList code, Assembly context) + { + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) + .Append(context.Location) + .Distinct(StringComparer.Ordinal) + .Select(path => MetadataReference.CreateFromFile(path)); + var compilation = CSharpCompilation.Create( + $"DesignTime{Guid.NewGuid():N}", + code.Select(source => CSharpSyntaxTree.ParseText(source)), + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + using var image = new MemoryStream(); + var emitted = compilation.Emit(image); + emitted.Success.ShouldBeTrue( + $"The generated code does not compile:{Environment.NewLine}{string.Join(Environment.NewLine, emitted.Diagnostics)}{Environment.NewLine}{string.Join(Environment.NewLine, code)}"); + + return Assembly.Load(image.ToArray()); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ForeignConverterTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ForeignConverterTests.cs new file mode 100644 index 0000000..21be5b7 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ForeignConverterTests.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.Linq.Expressions; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// .Value is the column only where the column holds .Value: where TypeKit's converter +/// stores the value object. A property converted by a converter of the application's own holds +/// something else, and the translation is refused rather than compared with what the column does not +/// hold. +/// +/// +/// Measured on SQLite before the check: with "C:" + c.Value stored, t.Code.Value == "abc" +/// became "t"."Code" = 'abc' and found no row, where the row holds abc in memory; with a +/// quantity stored as text, t.Quantity.Value > 10 became "t"."Quantity" > 10 and found +/// the row holding 7. +/// +public sealed class ForeignConverterTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public static TheoryData Conversions => [ForeignConversion.Lambda, ForeignConversion.ConverterType, ForeignConversion.ModelConvention]; + + [Theory] + [MemberData(nameof(Conversions))] + public void Value_on_a_column_another_converter_stores_is_refused(ForeignConversion conversion) + { + using var db = Seeded(conversion); + + Refusal(() => db.Coded.Count(c => c.Code.Value == "abc")).ShouldContain("could not be translated"); + Refusal(() => db.Coded.Count(c => c.Quantity.Value > 10)).ShouldContain("could not be translated"); + Refusal(() => db.Coded.Count(c => c.Quantity.GetValue() > 10)).ShouldContain("could not be translated"); + } + + /// The column still answers for the value object itself, through the application's converter. + [Theory] + [MemberData(nameof(Conversions))] + public void The_value_object_itself_still_compares_through_the_application_s_converter(ForeignConversion conversion) + { + using var db = Seeded(conversion); + + db.Coded.Count(c => c.Code == CodeText.From("abc")).ShouldBe(1); + db.Coded.Count(c => c.Quantity == ShipmentNo.From(7)).ShouldBe(1); + } + + /// The control: TypeKit's converter named explicitly, as the README shows, still translates .Value. + [Fact] + public void Value_on_a_column_TypeKit_s_converter_stores_explicitly_translates() + { + using var db = Seeded(ForeignConversion.ExplicitTypeKit); + + db.Coded.Count(c => c.Code.Value == "abc").ShouldBe(1); + db.Coded.Count(c => c.Quantity.Value > 10).ShouldBe(0); + db.Coded.Where(c => c.Code.Value == "abc").ToQueryString().ShouldContain("WHERE \"c\".\"Code\" = 'abc'"); + } + + /// + /// Beside a column the application converts, the columns TypeKit maps still translate: one over a + /// string, and one over an enum, whose converter is TypeKit's composed onto the enum's own. + /// + [Theory] + [MemberData(nameof(Conversions))] + public void Value_on_the_columns_TypeKit_maps_still_translates(ForeignConversion conversion) + { + using var db = Seeded(conversion); + + db.Model.FindEntityType(typeof(Coded))!.FindProperty(nameof(Coded.Day))!.GetTypeMapping().Converter.ShouldNotBeNull() + .ConvertToProviderExpression.Body.ShouldNotBeAssignableTo("the enum's mapping is expected to carry a converter of its own"); + + db.Coded.Count(c => c.Label.Value == "abc").ShouldBe(1); + db.Coded.Count(c => c.Day.Value == DayOfWeek.Friday).ShouldBe(1); + db.Coded.Count(c => c.Day.Value == DayOfWeek.Monday).ShouldBe(0); + } + + /// + /// A compiled model (dotnet ef dbcontext optimize) creates every converter again, from the + /// expressions it wrote out as C#. TypeKit's are still told from the application's, the one composed + /// onto an enum's included. + /// + [Fact] + public void In_a_compiled_model_Value_translates_only_where_TypeKit_s_converter_stores_the_column() + { + using var design = Seeded(ForeignConversion.Lambda); + var compiled = DesignTime.CompiledModel(design); + using var db = new ForeignConvertersDb( + new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().UseModel(compiled).Options, ForeignConversion.Lambda); + + db.Model.ShouldBeSameAs(compiled); + db.Coded.Count(c => c.Label.Value == "abc").ShouldBe(1); + db.Coded.Count(c => c.Day.Value == DayOfWeek.Friday).ShouldBe(1); + Refusal(() => db.Coded.Count(c => c.Code.Value == "abc")).ShouldContain("could not be translated"); + Refusal(() => db.Coded.Count(c => c.Quantity.Value > 10)).ShouldContain("could not be translated"); + } + + public void Dispose() => _connection.Dispose(); + + private ForeignConvertersDb Seeded(ForeignConversion conversion) + { + _connection.Open(); + var db = new ForeignConvertersDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit() + .ReplaceService() + .Options, conversion); + db.Database.EnsureCreated(); + db.Add(new Coded { Code = CodeText.From("abc"), Quantity = ShipmentNo.From(7), Label = LabelText.From("abc"), Day = ProbeWeekday.From(DayOfWeek.Friday) }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + return db; + } + + private static string Refusal(Func query) => Should.Throw(() => query()).Message; +} + +/// EF caches a model per context type; these need one per conversion. +public sealed class ModelPerConversion : IModelCacheKeyFactory +{ + public object Create(DbContext context, bool designTime) => + (context.GetType(), (context as ForeignConvertersDb)?.Conversion, designTime); +} + +public enum ForeignConversion +{ + /// HasConversion(to, from) with lambdas. + Lambda, + + /// HasConversion<TConverter>(). + ConverterType, + + /// ConfigureConventions: Properties<T>().HaveConversion<TConverter>(). + ModelConvention, + + /// HasConversion<ValueObjectConverter<,>>(): TypeKit's own, named. + ExplicitTypeKit, +} + +public sealed class Coded +{ + public int Id { get; set; } + + public CodeText Code { get; set; } = null!; + + public ShipmentNo Quantity { get; set; } + + /// Never configured: TypeKit maps it. + public LabelText Label { get; set; } = null!; + + /// Never configured: TypeKit's converter composed onto the enum's. + public ProbeWeekday Day { get; set; } +} + +/// +/// Record classes: a struct over a string cannot be a property in a compiled model (the package README's +/// first EF limitation). +/// +public sealed partial record CodeText : IValue; + +public sealed partial record LabelText : IValue; + +/// Stores a code upper-cased: not what .Value answers. +public sealed class UpperCodeConverter() : ValueConverter(code => code.Value.ToUpperInvariant(), text => CodeText.From(text.ToLowerInvariant())); + +/// Stores a number as text. +public sealed class TextQuantityConverter() + : ValueConverter(quantity => quantity.Value.ToString(CultureInfo.InvariantCulture), text => ShipmentNo.From(int.Parse(text, CultureInfo.InvariantCulture))); + +public sealed class ForeignConvertersDb(DbContextOptions options, ForeignConversion conversion) : DbContext(options) +{ + public DbSet Coded => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + if (conversion != ForeignConversion.ModelConvention) return; + + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var coded = modelBuilder.Entity(); + + switch (conversion) + { + case ForeignConversion.Lambda: + coded.Property(c => c.Code).HasConversion(PrefixedCode, code => CodeText.From(code.Substring(2))); + coded.Property(c => c.Quantity).HasConversion(quantity => quantity.Value.ToString(CultureInfo.InvariantCulture), text => ShipmentNo.From(int.Parse(text, CultureInfo.InvariantCulture))); + break; + case ForeignConversion.ConverterType: + coded.Property(c => c.Code).HasConversion(); + coded.Property(c => c.Quantity).HasConversion(); + break; + case ForeignConversion.ExplicitTypeKit: + coded.Property(c => c.Code).HasConversion>(); + coded.Property(c => c.Quantity).HasConversion>(); + break; + } + } + + public ForeignConversion Conversion => conversion; + + private static readonly Expression> PrefixedCode = code => "C:" + code.Value; +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs index c4fc1d8..5c365ab 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/MigrationSnapshotTests.cs @@ -1,15 +1,11 @@ using System.Reflection; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Design; using Microsoft.EntityFrameworkCore.Migrations.Operations; -using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; @@ -122,36 +118,14 @@ private static string Describe(MigrationOperation operation) /// The first migration, as dotnet ef migrations add writes it, with the provider's design-time services. private static ScaffoldedMigration Scaffold(DbContext db) { - // The order and the lookup of EF's DesignTimeServicesBuilder: the context, the provider's services - // (named by the provider assembly's attribute), then EF's own, which are only added where missing. - var services = new ServiceCollection().AddDbContextDesignTimeServices(db); - var provider = Assembly.Load(new AssemblyName(db.GetService().Name)); - var providerServices = provider.GetCustomAttribute().ShouldNotBeNull($"{provider} names no design-time services."); - ((IDesignTimeServices)Activator.CreateInstance(provider.GetType(providerServices.TypeName, throwOnError: true)!)!).ConfigureDesignTimeServices(services); - services.AddEntityFrameworkDesignTimeServices(); - - using var designTime = services.BuildServiceProvider(); + using var designTime = DesignTime.Services(db); return designTime.GetRequiredService().ScaffoldMigration("Initial", "SnapshotProbe"); } private static ModelSnapshot Compile(string code, Assembly context) { - var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) - .Append(context.Location) - .Distinct(StringComparer.Ordinal) - .Select(path => MetadataReference.CreateFromFile(path)); - var compilation = CSharpCompilation.Create( - $"Snapshot{Guid.NewGuid():N}", - [CSharpSyntaxTree.ParseText(code)], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); - - using var image = new MemoryStream(); - var emitted = compilation.Emit(image); - emitted.Success.ShouldBeTrue($"The scaffolded snapshot does not compile:{Environment.NewLine}{string.Join(Environment.NewLine, emitted.Diagnostics)}{Environment.NewLine}{code}"); - - var snapshot = Assembly.Load(image.ToArray()).GetTypes().Single(type => type.IsSubclassOf(typeof(ModelSnapshot))); + var snapshot = DesignTime.Compile([code], context).GetTypes().Single(type => type.IsSubclassOf(typeof(ModelSnapshot))); return (ModelSnapshot)Activator.CreateInstance(snapshot)!; } From 92dfe163e34a4f997d89c408b5d0a5c3a6fede55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:21:39 +0200 Subject: [PATCH 071/147] fix: a value-object key is generated at the root of a hierarchy, and not under TPC on SQLite The key convention ran first among the finalizing conventions. EntityTypeHierarchyMappingConvention, later in the same list, sets the mapping strategy on every hierarchy root (TPH where none is configured), and RelationalValueGenerationConvention then decides the root's key again from its CLR type. SQLite's provider reads the CLR type, so a TPH root keyed by a value object went back to never generated and the second entity added threw for a duplicate key; PostgreSQL's reads the provider type and kept its identity column. The convention now runs after the last of EF's finalizing conventions that make that decision again (the hierarchy mapping and the shared-table conventions), and treats the foreign key linking a TPT table to its base as EF does, as no reason not to generate. A second convention, last of all, follows EF's TPC rule for an integer key: generated only where the provider gave the key a sequence default (PostgreSQL, SQL Server), never on SQLite, where each table would number its own rows. KeyGenerationTests compares each mapping with the same hierarchy keyed by an int, on SQLite and PostgreSQL, and round-trips TPH and TPT on SQLite. Co-Authored-By: Claude Opus 5.5 --- docs/ef-core.md | 4 +- .../README.md | 7 +- ...ValueObjectKeyValueGenerationConvention.cs | 78 +++++++- .../KeyGenerationTests.cs | 184 ++++++++++++++++++ 4 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs diff --git a/docs/ef-core.md b/docs/ef-core.md index 6dd65d0..2e3d3ba 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -94,7 +94,9 @@ value object's type, since EF applies `ConfigureConventions` by CLR type. A single-column key over an integer is generated on add, as a key of that integer type is: an identity column on PostgreSQL and SQL Server, so switching an `int` key to a value object there changes no schema. A key over a `Guid` is the application's to assign, `Id = OrderId.New()`, a version 7 -`Guid` that sorts by creation time; for EF to generate it, configure `ValueGeneratedOnAdd()`. A composite +`Guid` that sorts by creation time; for EF to generate it, configure `ValueGeneratedOnAdd()`. The root +of a TPH or TPT hierarchy is generated too; under TPC, as for an `int` key, only where the provider has +a sequence for the hierarchy (PostgreSQL, SQL Server), not on SQLite. A composite key of value objects is `HasKey(x => new { x.Order, x.Line })`, as for primitives. ```csharp diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 0e2d89a..c742333 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -72,8 +72,11 @@ of that integer type is. On PostgreSQL and SQL Server it is an identity column, key to a value object there changes no schema. A key over a `Guid` is the application's to assign, `Id = OrderId.New()` (a version 7 Guid, which sorts by creation time), and EF inserts it as given. For EF to generate it instead, configure `ValueGeneratedOnAdd()` on the property; EF then also takes an -entity whose key is already set, reached through a navigation, for an existing one. An explicit -configuration always wins over these defaults. +entity whose key is already set, reached through a navigation, for an existing one. The root of an +inheritance hierarchy follows the same rule under TPH and TPT. Under TPC, as for an `int` key, the key +is generated only where the provider numbers the whole hierarchy from a sequence (PostgreSQL, SQL +Server); SQLite has none, so there the application assigns it. An explicit configuration always wins +over these defaults. **SQLite: configure integer keys with `UseAutoincrement()`.** SQLite fills such a key in, but EF's SQLite provider makes a key `AUTOINCREMENT` only when the property's CLR type is an integer, and a diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs index baf2447..defab9b 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs @@ -11,12 +11,34 @@ internal sealed class ValueObjectKeyConventionSetPlugin : IConventionSetPlugin { public ConventionSet ModifyConventions(ConventionSet conventionSet) { - // First among the finalizing conventions: a provider's value-generation strategy (an identity - // column on PostgreSQL and SQL Server) is chosen there, for keys already generated on add. - conventionSet.ModelFinalizingConventions.Insert(0, new ValueObjectKeyValueGenerationConvention()); + var finalizing = conventionSet.ModelFinalizingConventions; + + // After the last of EF's finalizing conventions that change what its value-generation convention + // re-reads, and so before a provider's value-generation strategy (an identity column on PostgreSQL + // and SQL Server), which is chosen for keys already generated on add and comes after them. + finalizing.Insert(IndexAfterTheLastReset(finalizing), new ValueObjectKeyValueGenerationConvention()); + + // Last of all: a TPC key is generated only where the provider gave it a sequence. + finalizing.Add(new ValueObjectTpcKeyValueGenerationConvention()); return conventionSet; } + + /// + /// EF's value-generation convention decides a key again, from its CLR type, whenever an entity type's + /// mapping strategy or table changes (RelationalValueGenerationConvention.ProcessEntityTypeAnnotationChanged), + /// and two finalizing conventions change them: sets + /// the mapping strategy on every hierarchy's root, TPH where none is configured, and + /// makes table names unique. Decided before them, the key of a TPH + /// root went back to never generated on SQLite, whose provider reads the CLR type, and the second + /// entity added threw for a duplicate key (measured with EF Core 10.0.12). + /// + private static int IndexAfterTheLastReset(List finalizing) + { + var last = finalizing.FindLastIndex(convention => convention is EntityTypeHierarchyMappingConvention or SharedTableConvention); + + return last + 1; + } } /// @@ -42,15 +64,51 @@ internal sealed class ValueObjectKeyValueGenerationConvention : IModelFinalizing public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) { foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) - { - if (entityType.BaseType is not null || entityType.FindPrimaryKey() is not { Properties: [var property] }) continue; - if (property.IsForeignKey()) continue; - - if (ValueObjectTypes.Describe(Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType) is { WrappedType: var wrapped } - && IsGeneratedByTheDatabase(wrapped)) + if (GeneratedKey(entityType) is { } property) property.Builder.ValueGenerated(ValueGenerated.OnAdd); - } + } + + /// The entity type's key, where it is a value object over an integer EF generates a key of. + internal static IConventionProperty? GeneratedKey(IConventionEntityType entityType) + { + if (entityType.BaseType is not null || entityType.FindPrimaryKey() is not { Properties: [var property] }) return null; + // As EF's own rule: the foreign key that links a TPT table to its base type's is no reason not to + // generate the key, and EntityTypeHierarchyMappingConvention has added it by now. + if (property.GetContainingForeignKeys().Any(foreignKey => !foreignKey.IsBaseLinking())) return null; + + return ValueObjectTypes.Describe(Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType) is { WrappedType: var wrapped } + && IsGeneratedByTheDatabase(wrapped) + ? property + : null; } private static bool IsGeneratedByTheDatabase(Type wrapped) => wrapped == typeof(int) || wrapped == typeof(long) || wrapped == typeof(short); } + +/// +/// A TPC hierarchy's key is generated only where the provider generates it from a sequence, as EF +/// decides for an integer key: each table of the hierarchy would otherwise number its own rows, and +/// two entities of different types would share a key. +/// +/// +/// EF refuses to generate an integer key under TPC (RelationalValueGenerationConvention.MappingStrategyAllowsValueGeneration) +/// unless the provider gives it a sequence, which PostgreSQL's and SQL Server's do, as the column's +/// default (nextval, NEXT VALUE FOR), and SQLite's does not. That default is what this +/// reads, after every provider convention, so no provider is named. +/// +internal sealed class ValueObjectTpcKeyValueGenerationConvention : IModelFinalizingConvention +{ + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + { + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + { + if (entityType.GetMappingStrategy() != RelationalAnnotationNames.TpcMappingStrategy) continue; + if (ValueObjectKeyValueGenerationConvention.GeneratedKey(entityType) is not { } property) continue; + if (property.GetValueGeneratedConfigurationSource() != ConfigurationSource.Convention) continue; + + if (StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) is not { } table + || RelationalValueGenerationConvention.GetValueGenerated(property, table) is null) + property.Builder.ValueGenerated(null); + } + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs new file mode 100644 index 0000000..1d44c5c --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs @@ -0,0 +1,184 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// A value-object key over an integer is generated as a key of that integer is, in every mapping of an +/// inheritance hierarchy, compared with the same hierarchy keyed by a plain int. +/// +/// +/// EF decides a hierarchy root's key generation again when it sets the root's mapping strategy, in a +/// convention that runs while the model is finalized, and SQLite's provider decides from the CLR type: +/// the root of a TPH hierarchy keyed by a value object went back to never generated, and the second +/// entity added threw for a duplicate key, while PostgreSQL kept its identity column. +/// +public sealed class KeyGenerationTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public static TheoryData Hierarchies => new() + { + { "sqlite", typeof(TphByValueObject), typeof(TphByInt) }, + { "sqlite", typeof(TptByValueObject), typeof(TptByInt) }, + { "sqlite", typeof(TpcByValueObject), typeof(TpcByInt) }, + { "npgsql", typeof(TphByValueObject), typeof(TphByInt) }, + { "npgsql", typeof(TptByValueObject), typeof(TptByInt) }, + { "npgsql", typeof(TpcByValueObject), typeof(TpcByInt) }, + }; + + /// + /// The same decision as for int: generated under TPH and TPT on both providers, and under TPC + /// only on PostgreSQL, from a sequence, since SQLite has none and each table would number its own rows. + /// + [Theory] + [MemberData(nameof(Hierarchies))] + public void A_hierarchy_root_s_key_is_generated_as_an_int_key_is(string provider, Type keyedByValueObject, Type keyedByInt) + { + using var db = Create(provider); + + var valueObjectKey = Key(db, keyedByValueObject); + var intKey = Key(db, keyedByInt); + + valueObjectKey.ValueGenerated.ShouldBe(intKey.ValueGenerated, $"{keyedByValueObject.Name} on {provider}"); + valueObjectKey.GetDefaultValueSql()?.Replace(keyedByValueObject.Name, "Root", StringComparison.Ordinal) + .ShouldBe(intKey.GetDefaultValueSql()?.Replace(keyedByInt.Name, "Root", StringComparison.Ordinal), $"{keyedByValueObject.Name} on {provider}"); + } + + /// What the review reproduced, on a database: a base entity and a derived one, each given its own key. + [Fact] + public void On_SQLite_a_TPH_root_s_key_is_generated_by_the_database() + { + _connection.Open(); + using var db = Create("sqlite"); + db.Database.EnsureCreated(); + + var root = new TphByValueObject { Name = "a" }; + var derived = new TphByValueObjectDerived { Name = "b", Extra = "x" }; + db.AddRange(root, derived); + db.SaveChanges(); + + root.Id.Value.ShouldBeGreaterThan(0); + derived.Id.Value.ShouldBeGreaterThan(0); + derived.Id.ShouldNotBe(root.Id); + } + + [Fact] + public void On_SQLite_a_TPT_root_s_key_is_generated_by_the_database() + { + _connection.Open(); + using var db = Create("sqlite"); + db.Database.EnsureCreated(); + + var root = new TptByValueObject { Name = "a" }; + var derived = new TptByValueObjectDerived { Name = "b", Extra = "x" }; + db.AddRange(root, derived); + db.SaveChanges(); + + root.Id.Value.ShouldBeGreaterThan(0); + derived.Id.ShouldNotBe(root.Id); + } + + public void Dispose() => _connection.Dispose(); + + private HierarchyKeysDb Create(string provider) => new(provider switch + { + "sqlite" => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options, + _ => new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options, + }); + + private static IProperty Key(DbContext db, Type root) => db.Model.FindEntityType(root)!.FindPrimaryKey()!.Properties.Single(); +} + +public class TphByValueObject +{ + public ShipmentNo Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TphByValueObjectDerived : TphByValueObject +{ + public string Extra { get; set; } = ""; +} + +public class TphByInt +{ + public int Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TphByIntDerived : TphByInt +{ + public string Extra { get; set; } = ""; +} + +public class TptByValueObject +{ + public ShipmentNo Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TptByValueObjectDerived : TptByValueObject +{ + public string Extra { get; set; } = ""; +} + +public class TptByInt +{ + public int Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TptByIntDerived : TptByInt +{ + public string Extra { get; set; } = ""; +} + +public class TpcByValueObject +{ + public ShipmentNo Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TpcByValueObjectDerived : TpcByValueObject +{ + public string Extra { get; set; } = ""; +} + +public class TpcByInt +{ + public int Id { get; set; } + + public string Name { get; set; } = ""; +} + +public class TpcByIntDerived : TpcByInt +{ + public string Extra { get; set; } = ""; +} + +/// Each mapping strategy twice, keyed by a value object over int and by int; TPH is EF's default. +public sealed class HierarchyKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTptMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTptMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + } +} From e644a8a19d79e373a84243fc817a8ea426e9e792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:23:49 +0200 Subject: [PATCH 072/147] fix: a value-object key over an unsigned integer, sbyte or char is generated as EF generates its type The convention generated keys over int, long and short only, while EF generates a key of every integer type but byte (ValueGenerationConvention.CanBeGenerated in EF Core 10): IValue, IValue, IValue, IValue and IValue keys were never generated, and an entity added without one was stored as 0. A value-object key now follows EF's own list for the type it wraps. KeyGenerationTests compares each key with a key of the wrapped type on SQLite and PostgreSQL, and has SQLite fill in a key over each integer type it generates (EF's own char key is AUTOINCREMENT on a TEXT column, which SQLite refuses, so char is compared on the model only). Co-Authored-By: Claude Opus 5.5 --- .../README.md | 5 +- ...ValueObjectKeyValueGenerationConvention.cs | 12 +- .../KeyGenerationTests.cs | 152 +++++++++++++++++- 3 files changed, 161 insertions(+), 8 deletions(-) diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index c742333..e16e39c 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -67,8 +67,9 @@ would be a `default` instance.) A property can still name the converter explicit modelBuilder.Entity().Property(o => o.Code).HasConversion>(); ``` -**Keys.** A single-column key over an integer (`int`, `long`, `short`) is generated on add, as a key -of that integer type is. On PostgreSQL and SQL Server it is an identity column, so switching an `int` +**Keys.** A single-column key over an integer is generated on add, as a key of that integer type is. +That is EF's own list: `int`, `long`, `short`, `sbyte`, `uint`, `ulong`, `ushort` and `char`, and not +`byte`. On PostgreSQL and SQL Server it is an identity column, so switching an `int` key to a value object there changes no schema. A key over a `Guid` is the application's to assign, `Id = OrderId.New()` (a version 7 Guid, which sorts by creation time), and EF inserts it as given. For EF to generate it instead, configure `ValueGeneratedOnAdd()` on the property; EF then also takes an diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs index defab9b..07776d9 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs @@ -49,7 +49,8 @@ private static int IndexAfterTheLastReset(List final /// /// /// -/// Only integers, where only the database can produce the key. A value object over a +/// The integer types are those EF generates a key of (), so a +/// value-object key follows what EF does for the type it wraps. A value object over a /// is left as EF leaves it, never generated: an identifier the application /// creates itself (OrderId.New()) is then always inserted, whereas a generated key that is set /// makes EF take a new entity reached through a navigation for an existing one. @@ -82,7 +83,14 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven : null; } - private static bool IsGeneratedByTheDatabase(Type wrapped) => wrapped == typeof(int) || wrapped == typeof(long) || wrapped == typeof(short); + /// + /// EF's own rule for a key of the wrapped type (ValueGenerationConvention.CanBeGenerated in + /// EF Core 10): every integer type but , the unsigned ones, and + /// included. + /// + private static bool IsGeneratedByTheDatabase(Type wrapped) => + wrapped == typeof(int) || wrapped == typeof(long) || wrapped == typeof(short) || wrapped == typeof(sbyte) + || wrapped == typeof(uint) || wrapped == typeof(ulong) || wrapped == typeof(ushort) || wrapped == typeof(char); } /// diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs index 1d44c5c..c51fb81 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs @@ -1,3 +1,4 @@ +using CodoMetis.TypeKit.ValueObjects; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; @@ -80,13 +81,69 @@ public void On_SQLite_a_TPT_root_s_key_is_generated_by_the_database() derived.Id.ShouldNotBe(root.Id); } + public static TheoryData IntegerKeys + { + get + { + var keys = new TheoryData(); + foreach (var provider in new[] { "sqlite", "npgsql" }) + foreach (var (byValueObject, byWrapped) in IntegerKeysDb.Pairs) + keys.Add(provider, byValueObject, byWrapped); + return keys; + } + } + + /// + /// EF generates a key of every integer type but byte, unsigned ones and char included + /// (ValueGenerationConvention), and a key over one follows it. Those over uint, + /// ulong, ushort, sbyte and char were never generated. + /// + [Theory] + [MemberData(nameof(IntegerKeys))] + public void A_key_over_an_integer_type_is_generated_as_a_key_of_that_type_is(string provider, Type keyedByValueObject, Type keyedByWrapped) + { + using var db = new IntegerKeysDb(Options(provider)); + + Key(db, keyedByValueObject).ValueGenerated.ShouldBe(Key(db, keyedByWrapped).ValueGenerated, $"{keyedByValueObject.Name} on {provider}"); + } + + /// + /// Each integer type SQLite fills a key of, added twice. Not char: EF's own char key asks + /// SQLite for a TEXT column with AUTOINCREMENT, which it refuses. + /// + [Fact] + public void On_SQLite_a_key_over_each_integer_type_is_generated_by_the_database() + { + _connection.Open(); + using var db = new SqliteIntegerKeysDb(Options("sqlite")); + db.Database.EnsureCreated(); + + KeyedByInt16[] shorts = [new(), new()]; + KeyedByInt64[] longs = [new(), new()]; + KeyedBySByte[] sbytes = [new(), new()]; + KeyedByUInt16[] ushorts = [new(), new()]; + KeyedByUInt32[] uints = [new(), new()]; + KeyedByUInt64[] ulongs = [new(), new()]; + db.AddRange([.. shorts, .. longs, .. sbytes, .. ushorts, .. uints, .. ulongs]); + db.SaveChanges(); + + shorts.Select(e => e.Id.Value).ShouldBe([(short)1, (short)2]); + longs.Select(e => e.Id.Value).ShouldBe([1L, 2L]); + sbytes.Select(e => e.Id.Value).ShouldBe([(sbyte)1, (sbyte)2]); + ushorts.Select(e => e.Id.Value).ShouldBe([(ushort)1, (ushort)2]); + uints.Select(e => e.Id.Value).ShouldBe([1u, 2u]); + ulongs.Select(e => e.Id.Value).ShouldBe([1ul, 2ul]); + } + public void Dispose() => _connection.Dispose(); - private HierarchyKeysDb Create(string provider) => new(provider switch + private HierarchyKeysDb Create(string provider) => new(Options(provider)); + + private DbContextOptions Options(string provider) where TDb : DbContext => provider switch { - "sqlite" => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options, - _ => new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options, - }); + "sqlite" => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options, + _ => new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options, + }; private static IProperty Key(DbContext db, Type root) => db.Model.FindEntityType(root)!.FindPrimaryKey()!.Properties.Single(); } @@ -182,3 +239,90 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(); } } + +public readonly partial record struct Int16Key : IValue; + +public readonly partial record struct Int64Key : IValue; + +public readonly partial record struct SByteKey : IValue; + +public readonly partial record struct UInt16Key : IValue; + +public readonly partial record struct UInt32Key : IValue; + +public readonly partial record struct UInt64Key : IValue; + +public readonly partial record struct ByteKey : IValue; + +public readonly partial record struct CharKey : IValue; + +public class KeyedByInt16 { public Int16Key Id { get; set; } } + +public class KeyedByShort { public short Id { get; set; } } + +public class KeyedByInt64 { public Int64Key Id { get; set; } } + +public class KeyedByLong { public long Id { get; set; } } + +public class KeyedBySByte { public SByteKey Id { get; set; } } + +public class KeyedBySbyte { public sbyte Id { get; set; } } + +public class KeyedByUInt16 { public UInt16Key Id { get; set; } } + +public class KeyedByUshort { public ushort Id { get; set; } } + +public class KeyedByUInt32 { public UInt32Key Id { get; set; } } + +public class KeyedByUint { public uint Id { get; set; } } + +public class KeyedByUInt64 { public UInt64Key Id { get; set; } } + +public class KeyedByUlong { public ulong Id { get; set; } } + +public class KeyedByByte { public ByteKey Id { get; set; } } + +public class KeyedByPlainByte { public byte Id { get; set; } } + +public class KeyedByChar { public CharKey Id { get; set; } } + +public class KeyedByPlainChar { public char Id { get; set; } } + +/// A key over each integer type, beside a key of that type itself. For the model only: SQLite refuses EF's own char key. +public sealed class IntegerKeysDb(DbContextOptions options) : DbContext(options) +{ + public static readonly (Type ByValueObject, Type ByWrapped)[] Pairs = + [ + (typeof(KeyedByInt16), typeof(KeyedByShort)), + (typeof(KeyedByInt64), typeof(KeyedByLong)), + (typeof(KeyedBySByte), typeof(KeyedBySbyte)), + (typeof(KeyedByUInt16), typeof(KeyedByUshort)), + (typeof(KeyedByUInt32), typeof(KeyedByUint)), + (typeof(KeyedByUInt64), typeof(KeyedByUlong)), + (typeof(KeyedByByte), typeof(KeyedByPlainByte)), + (typeof(KeyedByChar), typeof(KeyedByPlainChar)), + ]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + foreach (var (byValueObject, byWrapped) in Pairs) + { + modelBuilder.Entity(byValueObject); + modelBuilder.Entity(byWrapped); + } + } +} + +/// The keys over the integer types SQLite fills in. +public sealed class SqliteIntegerKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} From 717de543b0e967b345710dea43c5bf257c1d575e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:38:56 +0200 Subject: [PATCH 073/147] fix: a query that compares or orders a value object with its own CompareTo is refused A value object may declare CompareTo(TSelf), and its operators and interfaces follow it. EF dropped the operator and ordered by the column: with ProbeDescending holding 1, 2, 3 on SQLite, Rank > 2 answered 3 where memory answers 1, CompareTo(2) > 0 the same, OrderBy(Rank) sorted 1, 2, 3 where memory sorts 3, 2, 1, and Max(Rank) answered 3 where memory answers 1, without a word. The comparable aspect now puts [HandWrittenOrdering] (CodoMetis.TypeKit.CompilerServices, additive, EditorBrowsable(Never)) on a value object that declares the seam; a hand-written one is CMTK1011, as for every attribute the generators put on a type. UseTypeKit() registers an IQueryExpressionInterceptor that walks the LINQ tree once per query shape, before translation, and throws an InvalidOperationException naming the value object and pointing at .Value for a comparison (<, <=, >, >=, CompareTo, Compare), an ordering key (OrderBy, ThenBy and their descending forms, Order, OrderDescending, MinBy, MaxBy) or a Min/Max of such a type, nullable included. Ordering and comparing by .Value, equality, and value objects ordered by what they wrap are untouched. No translator sees these: EF translates a comparison without its operator method and an ordering key is a column, which is why the hook is the interceptor (measured: QueryCompilationStarting sees the operator, the CompareTo call, the OrderBy key and the Max selector with their value-object types). The attribute is read with GetCustomAttribute, never GetInterfaces. HandWrittenOrderingTests covers thirteen refused shapes and the allowed ones on SQLite; a record-class probe with the seam checks the detection of a nullable operand; the generated-surface snapshot gains the attribute. Co-Authored-By: Claude Opus 5.5 --- docs/ef-core.md | 9 +- docs/value-objects.md | 4 + .../HandWrittenOrderingInterceptor.cs | 99 ++++++++++++++ .../README.md | 8 ++ .../TypeKitServiceCollectionExtensions.cs | 8 +- .../ValueObjectTypes.cs | 6 + src/CodoMetis.TypeKit.Generators/README.md | 8 +- .../ValueObjectComparableAspect.cs | 14 ++ .../ValueObjectDeclaration.cs | 1 + .../HandWrittenOrderingAttribute.cs | 25 ++++ .../HandWrittenOrderingTests.cs | 122 ++++++++++++++++++ .../Probes.cs | 9 ++ .../BuildOutcomeTests.cs | 6 + .../FormattingAndComparisonTests.cs | 18 +++ .../GeneratedSurface.verified.txt | 41 ++++++ 15 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs create mode 100644 src/CodoMetis.TypeKit/CompilerServices/HandWrittenOrderingAttribute.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs diff --git a/docs/ef-core.md b/docs/ef-core.md index 2e3d3ba..2d4bced 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -144,8 +144,13 @@ db.Invoices.GroupBy(i => i.Currency).Select(g => new { Currency = g.Key, Sum = g A table's column is re-typed, not cast, so an index on it still serves the query. The element of a primitive collection, a property in a JSON column and a parameter are converted, which may add a cast. A property with a converter of the application's own does not hold `.Value`, so `.Value` on it -is refused ("could not be translated"); comparing the value object itself still works. Ordering in SQL follows the column's collation, while the generated comparison in memory is -ordinal for a string, as for any string column. +is refused ("could not be translated"); comparing the value object itself still works. + +Ordering in SQL follows the column's collation, while the generated comparison in memory is ordinal +for a string, as for any string column. A value object with its own `CompareTo(TSelf)` cannot be +compared, ordered, or taken the `Min` or `Max` of in a query, since the database orders the column by +the wrapped value: such a query is refused, naming the value object, and `.Value` says the wrapped +value's order is meant (`OrderBy(i => i.Priority.Value)`). ## Reading back without validation diff --git a/docs/value-objects.md b/docs/value-objects.md index 0600dde..8363236 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -231,6 +231,10 @@ var first = new[] { Priority.From(3), Priority.From(1) }.Min(); // 3 bool urgent = Priority.From(1) > Priority.From(2); // true ``` +A database cannot run a hand-written `CompareTo`, so an EF Core query that compares or orders such a +value object is refused ([EF Core](ef-core.md#queries)); order by `.Value` where the wrapped value's +order is meant. + A computed property, a static member and a constant are fine, as `Email.Domain` and `Quantity.Limit` above. A `ToString()` that masks a secret is in [Logging and secrets](logging-and-secrets.md). diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs new file mode 100644 index 0000000..e887854 --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -0,0 +1,99 @@ +using System.Linq.Expressions; +using System.Reflection; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// Refuses a query that compares, orders, or takes the minimum or maximum of a value object whose +/// ordering is hand-written (): the database orders its column +/// by the wrapped value, and CompareTo cannot run there. +/// +/// +/// +/// EF translates o.Priority > p as "o"."Priority" > @p, dropping the operator the +/// value object declares, rewrites o.Priority.CompareTo(p) > 0 the same way, and orders by the +/// column: with a descending CompareTo, every answer was the opposite of the in-memory one, +/// without a word (measured with EF Core 10.0.12 on SQLite). Comparing and ordering by .Value +/// stays allowed: that says the wrapped value's order is meant. +/// +/// +/// An interceptor because no translator sees these: EF translates a comparison without asking for the +/// operator's method, and an ordering key is a column, not a call. QueryCompilationStarting sees +/// the LINQ tree once per query shape, before any translation, with the value objects' types intact; +/// a precompiled query (Native AOT) passes through it at dbcontext optimize, which then fails. +/// +/// +internal sealed class HandWrittenOrderingInterceptor : IQueryExpressionInterceptor +{ + public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) + { + new Finder().Visit(queryExpression); + + return queryExpression; + } + + private sealed class Finder : ExpressionVisitor + { + protected override Expression VisitBinary(BinaryExpression node) + { + if (node.NodeType is ExpressionType.LessThan or ExpressionType.LessThanOrEqual or ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual) + { + Refuse(node.Left.Type, $"compared with {Operator(node.NodeType)}"); + Refuse(node.Right.Type, $"compared with {Operator(node.NodeType)}"); + } + + return base.VisitBinary(node); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + var method = node.Method; + + if (method.Name is nameof(IComparable.CompareTo) or nameof(IComparer<>.Compare)) + { + if (node.Object is not null) Refuse(node.Object.Type, $"compared with {method.Name}"); + foreach (var argument in node.Arguments) Refuse(argument.Type, $"compared with {method.Name}"); + } + else if ((method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable)) && OrderedType(method) is { } ordered) + { + Refuse(ordered, $"ordered by {method.Name}"); + } + + return base.VisitMethodCall(node); + } + + /// The type a LINQ operator orders by: the key, the element for Order, the result for Min and Max. + private static Type? OrderedType(MethodInfo method) => method.Name switch + { + nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) or nameof(Queryable.ThenBy) or nameof(Queryable.ThenByDescending) + or nameof(Queryable.MinBy) or nameof(Queryable.MaxBy) when method.IsGenericMethod => method.GetGenericArguments()[1], + nameof(Queryable.Order) or nameof(Queryable.OrderDescending) when method.IsGenericMethod => method.GetGenericArguments()[0], + nameof(Queryable.Min) or nameof(Queryable.Max) => method.ReturnType, + _ => null, + }; + + private static void Refuse(Type type, string how) + { + var candidate = Nullable.GetUnderlyingType(type) ?? type; + + if (!ValueObjectTypes.HasHandWrittenOrdering(candidate)) return; + + var wrapped = ValueObjectTypes.Describe(candidate)!.WrappedType; + + throw new InvalidOperationException( + $"{candidate.Name} is {how} in a query, but its ordering is its own CompareTo, which the database cannot run: " + + $"there the column orders by the {wrapped.Name} it holds, which need not be {candidate.Name}'s order. " + + $"Compare or order by .Value explicitly where the {wrapped.Name}'s order is meant, or do it in memory after the query."); + } + + private static string Operator(ExpressionType type) => type switch + { + ExpressionType.LessThan => "<", + ExpressionType.LessThanOrEqual => "<=", + ExpressionType.GreaterThan => ">", + _ => ">=", + }; + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index e16e39c..5f4c32d 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -136,6 +136,14 @@ Comparing the value object itself (`o.Code == code`) goes through that converter Ordering in SQL follows the database's collation for the column, while the generated comparison in memory is ordinal for a string, as it is for any string column. +A value object that declares its own `CompareTo(TSelf)` orders in memory by that method, which the +database cannot run: the column orders by the wrapped value, the opposite of a descending +`CompareTo`. A query that compares it (`<`, `>`, `<=`, `>=`, `CompareTo`), orders by it (`OrderBy`, +`ThenBy`, their descending forms, `Order`) or takes its `Min` or `Max` is therefore refused with an +`InvalidOperationException` naming the value object. Compare or order by `.Value` where the wrapped +value's order is meant (`OrderBy(o => o.Priority.Value)`), or order in memory after the query. +Equality is not ordering, and stays allowed. + The mapping is tested on SQLite and PostgreSQL, the SQL of each translation is pinned on PostgreSQL, and every wrapped-type family makes a round trip through a real PostgreSQL. diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs index aee6895..fcf0be1 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodoMetis.TypeKit.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; using Microsoft.EntityFrameworkCore.Query; @@ -21,13 +22,14 @@ public static IServiceCollection AddEntityFrameworkTypeKit(this IServiceCollecti { ArgumentNullException.ThrowIfNull(serviceCollection); - // All four are plugins, which EF collects from every registration: nothing of EF's or of - // another library's is replaced. + // Plugins and an interceptor, which EF collects from every registration: nothing of EF's or of + // another library's is replaced, and an application's own interceptors still run. new EntityFrameworkRelationalServicesBuilder(serviceCollection) .TryAdd() .TryAdd() .TryAdd() - .TryAdd(); + .TryAdd() + .TryAdd(); return serviceCollection; } diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs index beab43e..d2d90f4 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTypes.cs @@ -16,6 +16,7 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore; internal static class ValueObjectTypes { private static readonly ConcurrentDictionary Descriptions = new(); + private static readonly ConcurrentDictionary HandWrittenOrderings = new(); /// The value object's attribute, or for any other type. public static GeneratedValueObjectAttribute? Describe(Type type) => @@ -25,4 +26,9 @@ internal static class ValueObjectTypes : null); public static bool IsValueObject(Type type) => Describe(type) is not null; + + /// Whether is a value object that declares its own CompareTo(TSelf) (). + public static bool HasHandWrittenOrdering(Type type) => + HandWrittenOrderings.GetOrAdd(type, static candidate => + IsValueObject(candidate) && candidate.GetCustomAttribute(inherit: false) is not null); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index cda4e02..aabc6c7 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -328,7 +328,13 @@ the wrapped type's own message quotes the input. A refusal by `Create` names the differently, declare `CompareTo(TSelf)` yourself: it is kept, and the object overload, the operators and the interfaces are derived from it. Any other hand-written comparison member, an explicit implementation of `IComparable`, `IComparable` or `IComparisonOperators` included, is an error -(CMTK1008), so the ordering can never disagree with itself. +(CMTK1008), so in memory every comparison agrees with your `CompareTo`. + +A database cannot run it: it orders the column by the wrapped value. The type then carries +`[HandWrittenOrdering]`, and an EF Core query that compares, orders, or takes the `Min` or `Max` of +it is refused, naming the value object, rather than answered in the column's order +([CodoMetis.TypeKit.EntityFrameworkCore](../CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +Compare or order by `.Value` where the wrapped value's order is meant. ## Build errors diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index a65873f..6bcef68 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -1,6 +1,8 @@ using System.Numerics; +using CodoMetis.TypeKit.CompilerServices; using Metalama.Framework.Aspects; using Metalama.Framework.Code; +using Metalama.Framework.Code.DeclarationBuilders; namespace CodoMetis.TypeKit.Generators; @@ -51,6 +53,12 @@ public override void BuildAspect(IAspectBuilder builder) return; } + // The seam taken: run-time code that orders values where CompareTo cannot run, a database + // ordering a column by the wrapped value, reads this and refuses rather than answer the + // opposite order (the EF satellite). + if (DeclaresCompareTo(builder.Target)) + builder.IntroduceAttribute(AttributeConstruction.Create(typeof(HandWrittenOrderingAttribute))); + builder.Tags = new ComparableImplementationArguments { ValueType = valueType, @@ -149,6 +157,12 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe yield return member; } + /// The seam: a CompareTo(TSelf) the type declares itself, which the generated one then leaves in place. + private static bool DeclaresCompareTo(INamedType target) => + target.Methods.Any(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo) } + && method.Parameters is [var other] + && other.Type.Equals(target)); + /// /// The operand type: nullable for a record class, as IComparable<T>.CompareTo(T?) and /// the record's own equality operators declare it, so a caller passing null gets flow analysis diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index d45bd46..0f4fa81 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -288,6 +288,7 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta if (type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute))) || type.Equals(TypeFactory.GetNamedType(typeof(CompilerGeneratedAttribute))) || type.Definition.Equals(TypeFactory.GetNamedType(typeof(GeneratedValueObjectAttribute<,>))) + || type.Equals(TypeFactory.GetNamedType(typeof(HandWrittenOrderingAttribute))) || (parse != ValueParseStrategy.Unsupported && type.Equals(TypeFactory.GetNamedType(typeof(TypeConverterAttribute))))) found.Add($"the attribute {(type.Name.EndsWith(nameof(Attribute), StringComparison.Ordinal) ? type.Name.Substring(0, type.Name.Length - nameof(Attribute).Length) : type.Name)}"); } diff --git a/src/CodoMetis.TypeKit/CompilerServices/HandWrittenOrderingAttribute.cs b/src/CodoMetis.TypeKit/CompilerServices/HandWrittenOrderingAttribute.cs new file mode 100644 index 0000000..5ca96de --- /dev/null +++ b/src/CodoMetis.TypeKit/CompilerServices/HandWrittenOrderingAttribute.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace CodoMetis.TypeKit.CompilerServices; + +/// +/// Put by CodoMetis.TypeKit.Generators on a value object that declares its own CompareTo(TSelf): +/// its ordering, and the operators and interfaces derived from it, is that method, not the order of +/// the value it wraps. +/// +/// +/// +/// Code that orders values somewhere CompareTo cannot run reads it, from the value object's +/// , as it reads : the EF Core satellite +/// refuses a query that compares, orders or takes the minimum or maximum of such a value object, +/// since the database would order the column by the wrapped value, the opposite of the in-memory +/// answer for a descending CompareTo, and say nothing. +/// +/// +/// Nothing to write by hand: a hand-written one is CMTK1011, as for every attribute the generators put +/// on a value object. +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class HandWrittenOrderingAttribute : Attribute; diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs new file mode 100644 index 0000000..d374034 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -0,0 +1,122 @@ +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// A value object that declares its own CompareTo cannot be compared or ordered in SQL, where the +/// column orders by the wrapped value: the query is refused, naming the value object and .Value. +/// +/// +/// Measured on SQLite before the refusal, with holding 1, 2 and 3 (its +/// CompareTo orders them 3, 2, 1): Rank > 2 answered 3 where memory answers 1, +/// CompareTo(2) > 0 the same, OrderBy(Rank) 1, 2, 3 where memory sorts 3, 2, 1, and +/// Max(Rank) 3 where memory answers 1. +/// +public sealed class HandWrittenOrderingTests : IDisposable +{ + private static readonly ProbeDescending Two = ProbeDescending.From(2); + + private static readonly Dictionary> Refused = new() + { + ["greater than"] = db => db.Ranked.Where(r => r.Rank > Two).ToList(), + ["less than or equal"] = db => db.Ranked.Where(r => r.Rank <= Two).ToList(), + ["an optional one"] = db => db.Ranked.Where(r => r.Optional < Two).ToList(), + ["CompareTo"] = db => db.Ranked.Where(r => r.Rank.CompareTo(Two) > 0).ToList(), + ["in a projection"] = db => db.Ranked.Select(r => r.Rank >= Two).ToList(), + ["OrderBy"] = db => db.Ranked.OrderBy(r => r.Rank).ToList(), + ["OrderByDescending"] = db => db.Ranked.OrderByDescending(r => r.Rank).ToList(), + ["ThenBy"] = db => db.Ranked.OrderBy(r => r.Id).ThenBy(r => r.Rank).ToList(), + ["ThenByDescending"] = db => db.Ranked.OrderBy(r => r.Id).ThenByDescending(r => r.Optional).ToList(), + ["Order"] = db => db.Ranked.Select(r => r.Rank).Order().ToList(), + ["Min"] = db => db.Ranked.Min(r => r.Rank), + ["Max"] = db => db.Ranked.Select(r => r.Rank).Max(), + ["a record class"] = db => db.Ranked.OrderBy(r => r.Label).ToList(), + }; + + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public static TheoryData RefusedQueries => [.. Refused.Keys]; + + [Theory] + [MemberData(nameof(RefusedQueries))] + public void A_query_that_orders_a_value_object_by_its_own_CompareTo_is_refused(string query) + { + using var db = Seeded(); + + var refusal = Should.Throw(() => Refused[query](db)).Message; + + refusal.ShouldContain("its ordering is its own CompareTo, which the database cannot run"); + refusal.ShouldContain(query == "a record class" ? "ProbeReversedLabel" : "ProbeDescending"); + refusal.ShouldContain("Compare or order by .Value explicitly"); + } + + /// + /// .Value says the wrapped value's order is meant, equality is no ordering, and a value object + /// ordered by what it wraps orders in SQL as in memory. + /// + [Fact] + public void Ordering_by_Value_equality_and_a_generated_ordering_still_translate() + { + using var db = Seeded(); + + db.Ranked.Where(r => r.Rank.Value > 2).Select(r => r.Rank.Value).ToList().ShouldBe([3]); + db.Ranked.OrderByDescending(r => r.Rank.Value).Select(r => r.Rank.Value).ToList().ShouldBe([3, 2, 1]); + db.Ranked.Select(r => r.Rank).OrderBy(rank => rank.Value).ToList().ShouldBe([ProbeDescending.From(1), Two, ProbeDescending.From(3)]); + db.Ranked.Max(r => r.Rank.Value).ShouldBe(3); + db.Ranked.Count(r => r.Rank == Two).ShouldBe(1); + db.Ranked.Count(r => r.Rank != Two).ShouldBe(2); + + db.Ranked.Where(r => r.Count > ProbeCount.From(1)).OrderBy(r => r.Count).Select(r => r.Count.Value).ToList().ShouldBe([2, 3]); + db.Ranked.Max(r => r.Count).ShouldBe(ProbeCount.From(3)); + } + + /// Two value objects captured from the query's closure compare in memory, before EF sees the query. + [Fact] + public void A_comparison_EF_evaluates_in_memory_is_not_refused() + { + using var db = Seeded(); + var one = ProbeDescending.From(1); + + db.Ranked.Count(r => one > Two).ShouldBe(3); + } + + public void Dispose() => _connection.Dispose(); + + private RankedDb Seeded() + { + _connection.Open(); + var db = new RankedDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + db.Database.EnsureCreated(); + + foreach (var value in new[] { 1, 2, 3 }) + db.Add(new Ranked { Rank = ProbeDescending.From(value), Count = ProbeCount.From(value), Label = ProbeReversedLabel.From($"{value}") }); + + db.SaveChanges(); + db.ChangeTracker.Clear(); + + return db; + } +} + +public sealed class Ranked +{ + public int Id { get; set; } + + /// Ordered by its own CompareTo, descending. + public ProbeDescending Rank { get; set; } + + public ProbeDescending? Optional { get; set; } + + /// A record class ordered by its own CompareTo. + public ProbeReversedLabel Label { get; set; } = null!; + + /// Ordered by what it wraps. + public ProbeCount Count { get; set; } +} + +public sealed class RankedDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Ranked => Set(); +} diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 1b822ef..f6ff2d0 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -77,6 +77,15 @@ public static partial class ProbeContainer public int CompareTo(ProbeDescending other) => other.Value.CompareTo(Value); } +/// +/// The comparison seam on a record class, whose operand is nullable: the declared ordering must be +/// recognised there too. +/// +public sealed partial record ProbeReversedLabel : IValue +{ + public int CompareTo(ProbeReversedLabel? other) => other is null ? 1 : string.CompareOrdinal(other.Value, Value); +} + /// /// Declares its own ToString(), the formatting seam, to hide what it wraps: it is kept, and no /// formatting interface is generated, so interpolation and string.Format reach it too. A record diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 19fa453..310c0bc 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -78,6 +78,7 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1011", "HandWrittenMinValue")] [InlineData("CMTK1011", "HandWrittenJsonConverter")] [InlineData("CMTK1011", "HandWrittenTypeConverter")] + [InlineData("CMTK1011", "HandWrittenOrdering")] [InlineData("CMTK1011", "HandWrittenInterface")] [InlineData("CMTK1011", "ExplicitCreate")] [InlineData("CMTK1011", "ExplicitCreateBesidePublic")] @@ -230,6 +231,7 @@ public void A_part_a_source_generator_adds_does_not_count() => [InlineData("HandWrittenMinValue", "HandWrittenMinValue.MinValue")] [InlineData("HandWrittenJsonConverter", "the attribute JsonConverter")] [InlineData("HandWrittenTypeConverter", "the attribute TypeConverter")] + [InlineData("HandWrittenOrdering", "the attribute HandWrittenOrdering")] [InlineData("HandWrittenInterface", "the interface IValueObject")] [InlineData("ExplicitCreate", "Create only as an explicit implementation of IValidatedValue")] [InlineData("ExplicitCreateBesidePublic", "Create both as a static method and as an explicit implementation of IValidatedValue, so it is not generated: every generated way in calls ExplicitCreateBesidePublic.Create and generic code calling T.Create reaches the explicit one")] @@ -791,6 +793,10 @@ public static Result Create(string value) => [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.Int32Converter))] public readonly partial record struct HandWrittenTypeConverter : IValue; + // Says the ordering is hand-written where it is not, and EF would refuse to order it. + [CodoMetis.TypeKit.CompilerServices.HandWrittenOrdering] + public readonly partial record struct HandWrittenOrdering : IValue; + public readonly partial record struct HandWrittenInterface : IValue, IValueObject { public int Value => 0; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs index 86f9ad3..b247276 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs @@ -188,6 +188,24 @@ public void A_hand_written_CompareTo_is_the_seam_and_the_rest_follows_it() typeof(ProbeDescending).GetInterfaces().ShouldContain(typeof(IComparisonOperators)); } + /// + /// The seam taken is said on the type, for code that orders where CompareTo cannot run: the + /// EF satellite refuses to compare or order such a value object in SQL, where the column's order is + /// the wrapped value's. Only there: a value object ordered by what it wraps carries nothing. + /// + [Fact] + public void A_hand_written_CompareTo_is_said_on_the_type() + { + typeof(ProbeDescending).GetCustomAttribute().ShouldNotBeNull(); + typeof(ProbeReversedLabel).GetCustomAttribute().ShouldNotBeNull(); + + typeof(ProbeCount).GetCustomAttribute().ShouldBeNull(); + typeof(ProbeLabel).GetCustomAttribute().ShouldBeNull(); + typeof(ProbePin).GetCustomAttribute().ShouldBeNull(); + + (ProbeReversedLabel.From("a") > ProbeReversedLabel.From("b")).ShouldBeTrue(); + } + [Fact] public void The_non_generic_CompareTo_orders_null_first_and_refuses_another_type() { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 8028ebb..e32a27f 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -305,6 +305,7 @@ ProbeDescending Value : Int32 attribute CompilerGeneratedAttribute attribute GeneratedValueObjectAttribute + attribute HandWrittenOrderingAttribute attribute JsonConverterAttribute attribute TypeConverterAttribute implements IComparable @@ -1096,6 +1097,46 @@ ProbeRatio static op_LessThanOrEqual(ProbeRatio, ProbeRatio) : Boolean extension static ProbeRatioExtensions.GetValue(ProbeRatio) : Double extension static ProbeRatioExtensions.ValueOrNull(ProbeRatio?) : Double? +ProbeReversedLabel + $() : ProbeReversedLabel + CompareTo(Object) : Int32 + CompareTo(ProbeReversedLabel) : Int32 + Equals(Object) : Boolean + Equals(ProbeReversedLabel) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + Value : String + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute HandWrittenOrderingAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IParsable + implements IPlainValueObject + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ProbeReversedLabelJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ProbeReversedLabelTypeConverter : TypeConverter + static From(String) : ProbeReversedLabel + static Parse(String, IFormatProvider) : ProbeReversedLabel + static TryParse(String, IFormatProvider, out ProbeReversedLabel) : Boolean + static op_Equality(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_GreaterThan(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_GreaterThanOrEqual(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_Inequality(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_LessThan(ProbeReversedLabel, ProbeReversedLabel) : Boolean + static op_LessThanOrEqual(ProbeReversedLabel, ProbeReversedLabel) : Boolean + extension static ProbeReversedLabelExtensions.GetValue(ProbeReversedLabel) : String + extension static ProbeReversedLabelExtensions.ValueOrNull(ProbeReversedLabel) : String ProbeSecret $() : ProbeSecret CompareTo(Object) : Int32 From da9480616be136c26e9c65da598ebac02f4f9d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:42:29 +0200 Subject: [PATCH 074/147] docs: decisions 56 to 58, .Value translated only through TypeKit's converter, a hand-written ordering refused in a query, keys generated as EF generates the wrapped type's Co-Authored-By: Claude Opus 5.5 --- ...anslated-only-through-typekit-converter.md | 17 +++++++++++++++ ...-hand-written-ordering-refused-in-query.md | 18 ++++++++++++++++ .../adr/0058-integer-key-follows-ef-rule.md | 17 +++++++++++++++ design/adr/README.md | 3 +++ design/ef-core.md | 21 ++++++++++++++++--- design/packages.md | 4 ++-- 6 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 design/adr/0056-value-translated-only-through-typekit-converter.md create mode 100644 design/adr/0057-hand-written-ordering-refused-in-query.md create mode 100644 design/adr/0058-integer-key-follows-ef-rule.md diff --git a/design/adr/0056-value-translated-only-through-typekit-converter.md b/design/adr/0056-value-translated-only-through-typekit-converter.md new file mode 100644 index 0000000..0fb5329 --- /dev/null +++ b/design/adr/0056-value-translated-only-through-typekit-converter.md @@ -0,0 +1,17 @@ +# 0056. `.Value` is translated only on a column TypeKit's converter stores + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [EF Core](../ef-core.md). + +The `.Value`, `GetValue()` and `ValueOrNull()` translators re-typed any value-object column as the +wrapped type. A column the application maps with its own converter stores something else: with +`HasConversion(c => "C:" + c.Value, …)`, `t.Code.Value.StartsWith("a")` became `LIKE 'a%'` and +answered no row where memory answered one, and a quantity stored as text compared as a number +(measured on SQLite, through `HasConversion`, a converter type and `ConfigureConventions`). The +translators now act only where the operand's to-provider expression is +`ValueObjectConverter<,>.ProviderValue`, composed with an enum's own converter where the wrapped type +has one; anywhere else they decline and EF refuses the query. The expression is read rather than the +converter's type, since a compiled model recreates the converters (measured). A query that answered +wrong rows in 1.5.1 now throws, which is the change in behaviour 2.0.0 lists. diff --git a/design/adr/0057-hand-written-ordering-refused-in-query.md b/design/adr/0057-hand-written-ordering-refused-in-query.md new file mode 100644 index 0000000..3dc06cf --- /dev/null +++ b/design/adr/0057-hand-written-ordering-refused-in-query.md @@ -0,0 +1,18 @@ +# 0057. A query that orders by a hand-written `CompareTo` is refused + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; [EF Core](../ef-core.md), +[decision 28](0028-seams-declared-everything-else-refused.md). + +A value object may declare `CompareTo(TSelf)`, and the generated operators follow it. EF drops the +operator method and translates `<`, `OrderBy` and `Max` to the column's order, so a reversed +`Priority` answered the opposite of memory (measured on SQLite). Nothing can translate an arbitrary +C# ordering, so the query is refused rather than answered: the comparable aspect marks a type whose +ordering is hand-written with `[HandWrittenOrdering]` (`CodoMetis.TypeKit.CompilerServices`, +hidden; written by hand it is CMTK1011), and `UseTypeKit()` registers an `IQueryExpressionInterceptor` +that refuses the comparison operators, `CompareTo`/`Compare`, `OrderBy`/`ThenBy` and their descending +forms, `Order`, `MinBy`/`MaxBy` and `Min`/`Max` on such a type, nullable included, naming the value +object and saying to order by `.Value` where the column's order is meant. The interceptor sees the +LINQ tree before translation with the types intact (measured). diff --git a/design/adr/0058-integer-key-follows-ef-rule.md b/design/adr/0058-integer-key-follows-ef-rule.md new file mode 100644 index 0000000..872e7fd --- /dev/null +++ b/design/adr/0058-integer-key-follows-ef-rule.md @@ -0,0 +1,17 @@ +# 0058. A value-object key is generated wherever EF generates its wrapped type's + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the second outside review of 2.0.0; refines +[decision 23](0023-value-object-key-over-integer-generated.md). + +Decision 23 named `int`, `long` and `short`. EF generates every type its `IsInteger()` names except +`byte`: also `sbyte`, `uint`, `ulong`, `ushort` and `char`, so an `IValue` key stored 0 where a +plain `uint` key was generated (measured). The convention follows EF's rule exactly, `char` +included, whose own key SQLite refuses (`TEXT … AUTOINCREMENT`) as it refuses a plain `char` key. +Parity covers the hierarchy too: SQLite re-decided a TPH root's key from its CLR type when the +mapping strategy was set, after the convention had run, and stored 0; the convention now runs after +the hierarchy-mapping conventions, and a TPC key stays generated only where the provider gives it a +sequence, as a plain `int` key does (measured on SQLite and Npgsql; SQL Server's TPC read from EF's +source). diff --git a/design/adr/README.md b/design/adr/README.md index edaf748..c9c744b 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -62,3 +62,6 @@ per area ([design](../plan.md)) link to the records they rest on. | [0053](0053-fault-made-by-fault-of-alone.md) | A `Fault` is made by `Fault.Of` alone | 2026-10-13 | accepted | | [0054](0054-abstract-value-object-refused.md) | An abstract type with a value-object marker is refused (CMTK1022) | 2026-10-13 | accepted | | [0055](0055-component-name-claimed-twice-refused.md) | A component name two types claim is refused; a qualified fault name follows the nesting chain | 2026-10-13 | accepted | +| [0056](0056-value-translated-only-through-typekit-converter.md) | `.Value` is translated only on a column TypeKit's converter stores | 2026-10-13 | accepted | +| [0057](0057-hand-written-ordering-refused-in-query.md) | A query that orders by a hand-written `CompareTo` is refused | 2026-10-13 | accepted | +| [0058](0058-integer-key-follows-ef-rule.md) | A value-object key is generated wherever EF generates its wrapped type's | 2026-10-13 | accepted | diff --git a/design/ef-core.md b/design/ef-core.md index 3f9faac..adaf5f1 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -39,6 +39,17 @@ from a value object (or its `Nullable`) to what it wraps: the attribute is public, and a hand-marked `Length(this ProbeCode)` was translated as the column (`o."Code" = '3'`). Any other method carrying it is left to EF, which refuses the call. + - **Only on TypeKit's converter (decided 2026-10-13, [decision 56](adr/0056-value-translated-only-through-typekit-converter.md)).** + A column the application maps with its own `HasConversion` stores its own representation, and + `.Value` re-typed as the wrapped type queried the wrong one: `StartsWith("a")` against `"C:abc"` + answered no row. The translators act only where the operand's to-provider expression is + `ValueObjectConverter<,>.ProviderValue`, composed with an enum's converter where there is one, + which holds in a compiled model too; anywhere else EF refuses the query. + - **A hand-written ordering is refused in a query (decided 2026-10-13, [decision 57](adr/0057-hand-written-ordering-refused-in-query.md)).** + EF translates a comparison, an ordering and `Min`/`Max` to the column's order, not to a + hand-written `CompareTo`. The comparable aspect marks such a type `[HandWrittenOrdering]`, and an + `IQueryExpressionInterceptor` that `UseTypeKit()` registers refuses those operators on it before + translation, naming the value object; ordering by `.Value` stays allowed. - **The element of a primitive collection is converted, not re-typed (decided 2026-09-28).** EF infers a collection's element mapping from the columns that read its table expression (`unnest`, `json_each`, `OPENJSON`, `VALUES`), and a re-typed element column handed it the wrapped type's @@ -50,9 +61,13 @@ - **Keys over an integer are generated on add (decided 2026-09-28, [decision 23](adr/0023-value-object-key-over-integer-generated.md)).** EF's value generation convention reads the property's CLR type, so a value-object key was never generated: an entity added without one was stored as 0, and switching an `int` key to a value object dropped its - identity column. A convention plugin (first among the model-finalizing conventions, so a provider's - identity strategy sees it) marks a single-column, non-foreign key over `int`, `long` or `short` as - generated on add, at convention precedence. A key over a `Guid` is left alone: the application + identity column. A convention plugin marks a single-column, non-foreign key over the integer types + EF generates (`int`, `long`, `short`, `sbyte`, the unsigned ones and `char`, as EF's `IsInteger()` + minus `byte`) as generated on add, at convention precedence. It runs after the last + hierarchy-mapping and shared-table convention: SQLite re-decides a key from its CLR type when the + root's mapping strategy is set, which reset a TPH root's key to not generated, and a TPC key stays + generated only where the provider gave it a sequence, as for a plain `int` + ([decision 58](adr/0058-integer-key-follows-ef-rule.md)). A key over a `Guid` is left alone: the application assigns `OrderId.New()`, and a generated key that is already set makes EF take a new entity reached through a navigation for an existing one. - **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` diff --git a/design/packages.md b/design/packages.md index ab506ad..0005ab3 100644 --- a/design/packages.md +++ b/design/packages.md @@ -2,7 +2,7 @@ | Package | Depends on | Metalama | Contents | |---|---|---|---| -| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault` and `Fault.Of`; `Patch`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` | +| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault` and `Fault.Of`; `Patch`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` | | `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes ([Analyzers](analyzers.md)). Nobody references it directly: it reaches every consumer through the base package | | `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects (the value-object family and `ClosedSetAspect`), internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | | `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` | @@ -24,7 +24,7 @@ objects from a referenced domain assembly never needs Metalama itself. | `CodoMetis.TypeKit` (2.0.0) | `Fault`, `Patch`, `PatchState`, `PatchJsonConverterFactory`, `StringTextExtensions`, `TextFault`, the constraint attributes, `RequireParameterlessConstructorAttribute`, `ConstructorAccessibility` | | `CodoMetis.TypeKit.ClosedSets` | `IClosedSet<>`, `IFault`, `IFaultSet<>`, `FaultKind`: what a user declares a closed set or a fault set with | | `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | -| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `IValueObjectVisitor`, `TranslatedAsWrappedValueAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | +| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `GeneratedClosedSetAttribute`, `IValueObjectVisitor`, `IClosedSetVisitor`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan`, `GeneratedClosedSets`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | | `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`; the plugins, internal | | `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem`, `FaultResponseOptions`, `IFaultFeature` | | `Microsoft.AspNetCore.Http` | `ToHttpResult()`, beside ASP.NET's own `Results` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | From b14a95dd8cb04f06062eda1baf1dca7031ba40a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 21:43:54 +0200 Subject: [PATCH 075/147] docs: the 2.0.0 changelog lists this review's changes, and 1.5.1 is what it shipped Commit f220e34 had filed the first review's fixes under 1.5.1, which shipped none of the features they fix (closed sets, Fault, Catch, CMTK0010 are 2.0.0's); the section is restored to the v1.5.1 tag's text. The 2.0.0 section describes what 2.0.0 does rather than earlier states of its own new features, drops the design-history sentence, and lists the behaviour that changes against 1.5.1: CMTK0003's new reach, CMTK1022, the EF Core refusals and key generation, the OpenAPI collision, the async combinators' null-task refusal and the Testing package's type names. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 99 +++++++++++++++++++++++++++------------------------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e30453..b465dc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,10 @@ section is the GitHub release's notes. A major version: it targets `net10.0` and Metalama 2026.1 as 1.x does, and the breaks are the ones Semantic Versioning reserves for a major, listed under Changed. Package validation against 1.5.1 finds one difference in the public surface, `FaultProblem`'s constraint, which still accepts -every type argument it accepted; the others are behaviour no compatibility check reads, CMTK0003 at -Error and `FirstOrNone` on a query. The new Error rules, CMTK0010 to CMTK0012, report only on what -this release adds, so nothing that built against 1.5.1 meets them. +every type argument it accepted; the others are behaviour no compatibility check reads: CMTK0003 at +Error, `FirstOrNone` on a query, CMTK1022, and the EF Core and OpenAPI refusals below. The new +analyzer Errors, CMTK0010 to CMTK0012, report only on what this release adds, so nothing that built +against 1.5.1 meets them. ### Added @@ -33,7 +34,7 @@ this release adds, so nothing that built against 1.5.1 meets them. - A `ValueTask`, or a task spelled as the type argument, is refused before the delegate runs. - A delegate that returns null throws as `Result.Success(null)` does, and so does one given to `CatchAsync` that returns no task. -- **The closed-set family**, the release's headline: `IClosedSet` in `CodoMetis.TypeKit`, generated +- **The closed-set family**, the release's headline: `IClosedSet` in the `CodoMetis.TypeKit` package, generated by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static readonly` field, with constant data through a private constructor where the members carry some. The generators add `Name`, `QualifiedName`, `All` in declaration order and read-only, `Match` with one required delegate @@ -46,11 +47,13 @@ this release adds, so nothing that built against 1.5.1 meets them. generic, or with a member not initialized with `new` where it is declared; CMTK1020 refuses a record. A hand-written `ToString()` is kept, and an overload of a generated method with another signature; any other generated member written by hand is CMTK1011. An instance the set's - own code makes beside its members has no name, and reading it throws rather than write a null. + own code makes beside its members has no name, and reading it throws rather than writing a null. + `All` and the name lookups throw while the set is still initialising, so a static field + initializer of the set itself never reads a half-built one. CMTK0002 covers a closed set without the generators. CMTK1021 refuses a member named as a generated member, or two whose names differ only in case - (`Open` and `open`, `Express` and `EXPRESS`): one name to `TryParse(name, ignoreCase: true)`, and - to `Match`'s parameters where the first letter is the difference. `Match` checks every delegate for null before it picks one, + (`Open` and `open`, `Express` and `EXPRESS`), which collide in `TryParse(name, ignoreCase: true)`, and + in `Match`'s parameters where the first letter is the difference. `Match` checks every delegate for null before it picks one, and a set is a JSON dictionary key by its name. `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name, in a primitive collection and a JSON column too, through a compiled model and under Native AOT @@ -63,8 +66,8 @@ this release adds, so nothing that built against 1.5.1 meets them. - **`CodoMetis.TypeKit`**: `Patch`, a field of a JSON Merge Patch (RFC 7396): absent, null or a value, where System.Text.Json alone cannot tell an absent property from a null one. `default` is absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never - refuses a null; a value object inside a patch keeps its own rule. `Match`, `TryGetValue`, `IfValue`, - `IfPresent` and `ToOption` apply it without reflection; `IfPresent` hands a clear over as null, a + refuses a null; a value object inside a patch keeps its own rule. `Match`, `TryGetValue`, `Tap`, + `IfPresent` and `ToOption` apply it without reflection, `Tap` and `IfPresent` returning the patch; `IfPresent` hands a clear over as null, a `T?` over a struct too, never as `default(T)`. `Map` changes a value and keeps a clear and an absence, and `Traverse` validates a value with a `Create`'s own fault, `request.Email.Traverse(Email.Create)` as a `Result, EmailFault>`, a clear and an @@ -77,14 +80,14 @@ this release adds, so nothing that built against 1.5.1 meets them. - **`CodoMetis.TypeKit.AspNetCore`**: `AddTypeKit()` describes a `Patch` property as `T`'s schema, inlined, not required and admitting null, in 3.1 and 3.0; a value object inside a patch through the type it wraps. Null joins what restricts the values, so a validator accepts it beside every value - the argument accepts: an enum's list, where a string enum's patch had admitted nothing and a closed - set's had refused null, the type, or a branch beside the argument's own; an `object`'s schema stays - unrestricted, where it had admitted only null. + the argument accepts: an enum's list, the type, or a branch beside the argument's own; an `object`'s + schema stays unrestricted. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0010, the constraints a type parameter cannot declare. `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` (new in the base package) on a type parameter say what C# has no `where` for, and the rule reports a type argument that breaks it at any closed use: a base type, a member, a parameter, a local, a `typeof`, a call or a method group with its - arguments written or inferred. A type parameter forwarded without the attribute is a breach too, so the requirement + arguments written or inferred, a C# 14 extension member, and a generic attribute. A method that + implements an interface's, directly, statically or through a base class, carries its constraint. A type parameter forwarded without the attribute is a breach too, so the requirement follows the generic chain; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0011, with the package's first code fix. A type marked `[RequireParameterlessConstructor]` (new in the base package, with `ConstructorAccessibility`) @@ -113,12 +116,11 @@ this release adds, so nothing that built against 1.5.1 meets them. `Queryable.FirstOrDefault`. - **`CodoMetis.TypeKit.Generators`**: a closed set implements `IParsable` explicitly, beside its own `Parse` and `TryParse`, and carries a type converter that reads and writes the member's - name, so the configuration binder binds a set where it bound null without a word, and generic - code parses one through `T.Parse`. A hand-written `IParsable`, `[JsonConverter]` or - `[TypeConverter]` is CMTK1011, where it failed inside the generated code. + name, so the configuration binder binds a set and generic code parses one through `T.Parse`. A + hand-written `IParsable`, `[JsonConverter]` or `[TypeConverter]` is CMTK1011. - **`CodoMetis.TypeKit.Serilog`**: `Destructure.WithTypeKit()` logs a closed set as what it prints, - its name or its own `ToString()`, where Serilog destructured it by its properties and logged its - constant data, `{ Rank: 2, Perk: "…", Name: "Premium", QualifiedName: … }`. + its name or its own `ToString()`, never its constant data, which Serilog alone would destructure + by its properties. - **`CodoMetis.TypeKit`**: an option's asynchronous steps, `MapAsync`, `BindAsync` and `TapAsync` over callbacks that return a `Task`, and the continuations of a `Task>`: `MapAsync`, `BindAsync`, `FilterAsync`, `TapAsync` and `ToResultAsync`, so a repository's lookup continues into @@ -148,20 +150,20 @@ this release adds, so nothing that built against 1.5.1 meets them. value-object, result and smart-enum libraries, moved out of the README with its dated tables. Every C# block in them is compiled and its stated outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete public surface, checked against the types. The package READMEs remain the reference for each - package. The plan moved from `docs/plan.md` to `design/plan.md`, and on 2026-10-13 was split: a decision - record per decision under `design/adr/`, a design note per area, a roadmap, and `design/plan.md` - as the map; the phase log stays in git. + package. - **Fault responses a client can act on.** In `CodoMetis.TypeKit`: `FaultKind`, `IFault` and - `IFaultSet`, a closed set whose members carry a kind and an optional description, and - `Fault`, a fault with a sentence about one occurrence (`Fault.Of(reason, detail)`). In - `CodoMetis.TypeKit.AspNetCore`, each opt-in, with decision 38's default response unchanged: a closed + `IFaultSet` (in `CodoMetis.TypeKit.ClosedSets`), a closed set whose members carry a kind and + an optional description, and `Fault`, a fault with a sentence about one occurrence, made by + `Fault.Of(reason, detail)` alone and never compared with a bare reason. In + `CodoMetis.TypeKit.AspNetCore`, each opt-in, with 1.5's default response unchanged: a closed set as the fault type, `AddFaultResponses()` answering every member by its kind (`NotFound` 404, `Conflict` 409, `Invalid` 422, `Forbidden` 403, `Unavailable` 503) with its description as the `detail`; a `FaultResponse` per member with a `type` and a `title`; a `Fault`'s detail as the problem's `detail`; `WithFaultStatus(fault, status)` on an endpoint and `[FaultStatus]` on an MVC action, for that endpoint's response and document entry; `ToHttpResult(success)`, the endpoint's own success such as `Created`; and `IFaultFeature` on the request while the problem is - written, for the host's `CustomizeProblemDetails`, with `FaultResponseOptions.QualifiedFaultNames`. + written, for the host's `CustomizeProblemDetails`, with `FaultResponseOptions.QualifiedFaultNames`, + which names a fault after its type's nesting chain (`Billing.Fault.NotFound`). The startup check refuses a status for a fault type the endpoint does not answer with, and an attribute naming no member. @@ -182,34 +184,35 @@ this release adds, so nothing that built against 1.5.1 meets them. body never reads them; and a lazy sequence of results a call made, materialised by `ToList()` or `ToListAsync()` and dropped. CMTK0005, CMTK0006, CMTK0008 and CMTK0009 stay Warnings, since correct code can look the same and has nothing but a suppression to say so, and CMTK0007 stays Info. +- **`CodoMetis.TypeKit.Analyzers`**: CMTK0003 also reports a result picked by `??`, a dropped tuple + holding one, a dropped dictionary of them, and a call's task passed through `ConfigureAwait` or + `WaitAsync` and dropped, awaited or not; a task the caller still holds stays silent through both. +- **`CodoMetis.TypeKit.Generators`**: an abstract type that declares a value-object marker is CMTK1022, + an Error. It generated nothing, and a value held as it was written as `{}`; declare the marker on + the sealed type. +- **`CodoMetis.TypeKit.EntityFrameworkCore`**: `.Value`, `GetValue()` and `ValueOrNull()` are translated + only on a column TypeKit's converter stores. On a column the application maps with its own + converter they queried the wrong representation and answered wrong rows; EF now refuses the query. +- **`CodoMetis.TypeKit.EntityFrameworkCore`**: a query that compares, orders, or takes `Min`/`Max` of a + value object that declares its own `CompareTo` is refused, naming the value object. It was + translated to the column's order, the opposite of the in-memory one for a reversed ordering; order + by `.Value` where the column's order is meant. +- **`CodoMetis.TypeKit.EntityFrameworkCore`**: a value-object key is generated on add wherever EF + generates its wrapped type's: over `sbyte`, `uint`, `ulong`, `ushort` and `char` too, and at the + root of a TPH hierarchy on SQLite, which stored 0. A TPC key on SQLite is not generated, as a plain + `int` key is not. A migration of such a model adds the identity. +- **`CodoMetis.TypeKit.AspNetCore`**: a document in which a nested value object's or closed set's + component name, `Order.Status` as `OrderStatus`, is also another type's throws, naming both; one + component described both as one of them. +- **`CodoMetis.TypeKit`**: every asynchronous combinator given a delegate that returns no task throws + an `ArgumentNullException` naming it, where the await threw a `NullReferenceException`. +- **`CodoMetis.TypeKit.Testing`**: a failure names a nested type after its containing types, + `Option`; a type nested in a generic one threw `ArgumentOutOfRangeException` instead. ## 1.5.1 — 2026-10-11 ### Fixed -- **`CodoMetis.TypeKit.Generators`**: a closed set declared in two partial files listed the members of - the second as null in `All`, and the names the satellites read threw; `All` is filled after every - part's fields. Two nested sets of one simple name shared one `QualifiedName` prefix; it is the - nesting chain, `Order.Status.Open`. A non-public static field of the set's own type compiled as no - member, whose name threw and which `TryParse` never found; it is CMTK1017. The nested JSON and type - converters are sealed. -- **`CodoMetis.TypeKit.AspNetCore`**: a closed set was a string enum as a body property only; as a route - or query parameter it was a bare `string`, and in a list, an array parameter or a dictionary its - element was missing. It is the enum in every position, as a value object is described in each. Two - nested sets of one simple name shared one component; each is named after its nesting chain. -- **`CodoMetis.TypeKit`**: `ElementAtOrNone(^n)` on a query bound to the sequence overload and read every - row; it is a compile error naming `ElementAtOrNone(int)` and `AsEnumerable()`. `fault == reason` on a - `Fault` compiled through the implicit conversion and was false whenever the fault carried a - detail; the mixed comparisons and `Equals(reason)` are compile errors naming `Reason`. `CatchAsync` - given a delegate that returned no task answered an error holding the `NullReferenceException` the - await threw; it throws before anything is awaited, as `Result.Success(null)` does. -- **`CodoMetis.TypeKit.Analyzers`**: CMTK0010 reported a `` in a documentation - comment, and called an array not sealed; a cref is no use, and an array is sealed and concrete. A - method implementing an interface's method, or overriding one, without repeating the attribute let a - call through the class pass; it carries the constraint of what it implements. CMTK0003 missed a - result task picked by a conditional or a `switch` expression, awaited or converted to a plain task, - and a method group of a method answering a collection of results, converted to a delegate that - returns a plain task. - **`CodoMetis.TypeKit.AspNetCore`**: a value object bound as a route, query or header parameter is described as ASP.NET describes a parameter of the wrapped type. ASP.NET uses a type's own schema there only for a primitive, an enum, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, From f8b38936b75e033e337c7ec8b3dd11f8b851d136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:50:29 +0200 Subject: [PATCH 076/147] docs: the migration guide lists CMTK1022, the refusals and the CMTK0003 forms 2.0 adds, and the review's smaller doc corrections - docs/migrating-to-2.md: CMTK1022 with its recipe (compiled), the three queries and documents that ran wrongly and now throw, rebuilding libraries and compiled models, messages that changed, and the CMTK0003 forms as measured against 1.5.1 (WaitAll/WaitAny over separate arguments were already reported). - SECURITY.md: 2.x is the supported line, and all seven packages are listed. - README: a refused dictionary key is in JsonException.Path; a DateTime and number handling on the containing type are the JSON exceptions; four satellites, not two. - docs/logging-and-secrets.md: the dictionary-key exception. - CONTRIBUTING.md: the smoke test publishes two Native AOT consumers. - design/packages.md: the namespace table lists every public type. - Decision 43 names the attribute as shipped. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 6 +- README.md | 18 +++-- SECURITY.md | 7 +- ...ument-constraints-constructor-guarantee.md | 2 +- design/packages.md | 9 +-- docs/logging-and-secrets.md | 5 +- docs/migrating-to-2.md | 70 +++++++++++++++++-- .../Docs/MigratingSamples.cs | 8 +++ 8 files changed, 101 insertions(+), 24 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e700d9a..b8cc271 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,9 +12,9 @@ dotnet test --solution CodoMetis.TypeKit.slnx ``` The EF Core tests start a PostgreSQL container, so the suite needs Docker. The smoke test publishes -one consumer with Native AOT, which needs the platform's native toolchain: clang (Xcode's command-line -tools on macOS; `clang` and `zlib1g-dev` on Debian and Ubuntu). Shipping projects live -under `src/` and test projects under `test/`; the topic pages a consumer reads are in `docs/`, each +two consumers with Native AOT, a web host and a console application with EF Core, which needs the +platform's native toolchain: clang (Xcode's command-line tools on macOS; `clang` and `zlib1g-dev` on +Debian and Ubuntu). Shipping projects live under `src/` and test projects under `test/`; the topic pages a consumer reads are in `docs/`, each compiled like the READMEs, and the plan is in `design/`. [AGENTS.md](AGENTS.md) is the architecture guide and [design/plan.md](design/plan.md) the map of the design notes, the decision records and the roadmap. Read both before changing `Option`/`Result`, an aspect, the fabric, the analyzer or a diff --git a/README.md b/README.md index 9b919ae..6dd5464 100644 --- a/README.md +++ b/README.md @@ -167,12 +167,16 @@ compiled and its stated outputs are tested, as the READMEs' are. rule, or the wrapped type it could not read, and carries no inner exception that quotes the text; `FromKnownGood` names the caller's expression, and `Option` and `Result` print nothing. So a value that is a secret does not reach a message or a log through this package, as long as your fault - type does not carry it. + type does not carry it. The exception is System.Text.Json's own: a refused dictionary key is in + the `JsonException`'s `Path`, and so in MVC's 400 + ([Logging and secrets](docs/logging-and-secrets.md#refusals-never-quote-the-input)). - **Swapping a primitive for a value object keeps the contract.** Its JSON is the wrapped type's - under your options; its column is the wrapped type's, a key over an integer still an identity - column; its OpenAPI schema is the one ASP.NET publishes for the wrapped type. What changes is what - the value object refuses, a JSON `null` for a struct value object included, and under a - source-generated context an enum-backed value object needs its enum listed + under your options, apart from a `DateTime`, which is written as UTC, and number handling set by an + attribute on the containing type, which no converter sees + ([JSON](docs/json.md#the-wrapped-types-json)); its column is the wrapped type's, a key over an + integer still an identity column; its OpenAPI schema is the one ASP.NET publishes for the wrapped + type. What changes is what the value object refuses, a JSON `null` for a struct value object + included, and under a source-generated context an enum-backed value object needs its enum listed ([JSON](src/CodoMetis.TypeKit.Generators/README.md#json)). - **Loud failures.** A declaration that cannot be generated is a build error naming the declaration, CMTK1000 to CMTK1022, never a type with nothing in it. The analyzers make `default` @@ -217,8 +221,8 @@ A declaration that cannot be generated fails the build with its name on it, CMTK instead of compiling to less than it appears to. And Metalama runs the analyzers on the code it transformed, which is how they reach Razor components. -Metalama is in `CodoMetis.TypeKit.Generators` alone. `CodoMetis.TypeKit`, the analyzers and both -satellites do not reference it, so a project that only wants `Option` and `Result` never receives +Metalama is in `CodoMetis.TypeKit.Generators` alone. `CodoMetis.TypeKit`, the analyzers and the +four satellites do not reference it, so a project that only wants `Option` and `Result` never receives it. It is open source under the MIT license, and building with it needs no license key, here or in your projects. Thanks to the Metalama team. diff --git a/SECURITY.md b/SECURITY.md index 9784329..69a93d4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ subject. Helpful to include, as far as you have it: - The package and version (`CodoMetis.TypeKit`, `.Analyzers`, `.Generators`, - `.EntityFrameworkCore`, `.AspNetCore`) + `.EntityFrameworkCore`, `.AspNetCore`, `.Serilog`, `.Testing`) - The value object declaration involved, and whether the value came from application code or from parsed input (JSON, route, query string, database) - A minimal reproduction @@ -41,8 +41,9 @@ for going public, whatever the fix status. Reporters are credited unless they as ## Supported versions -Security fixes land on the latest minor version of the latest major version, 1.x at present. The -packages share one version, so a fix in any of them is a release of all of them. +Security fixes land on the latest minor version of the latest major version, 2.x at present; 1.x +gets none once 2.0.0 is released, and [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) lists what +the move changes. The packages share one version, so a fix in any of them is a release of all of them. ### If this project stops being maintained diff --git a/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md index bdbe36f..9304f83 100644 --- a/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md +++ b/design/adr/0043-exceptions-type-argument-constraints-constructor-guarantee.md @@ -10,7 +10,7 @@ out, since it is `Result` renamed, and `Result.Catch` takes its on stays out, since `Result` is the value-less shape. Constraints a type parameter cannot declare (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`) ship as attributes and an analyzer rule, not as an aspect, because the violation is at a use site that need not compile with -Metalama. `[EnsureParameterlessConstructor]` ships as an attribute, an analyzer rule and a code +Metalama. `[EnsureParameterlessConstructor]` (shipped as `[RequireParameterlessConstructor]`) ships as an attribute, an analyzer rule and a code fix, not as an inheritable aspect, because its value is the error and an introduced constructor would demand `partial` of every implementer and be silent without Metalama. The written-text guards ship as string extensions and a fault in the base package, with no value object and no diff --git a/design/packages.md b/design/packages.md index 0005ab3..ed181ef 100644 --- a/design/packages.md +++ b/design/packages.md @@ -25,13 +25,14 @@ objects from a referenced domain assembly never needs Metalama itself. | `CodoMetis.TypeKit.ClosedSets` | `IClosedSet<>`, `IFault`, `IFaultSet<>`, `FaultKind`: what a user declares a closed set or a fault set with | | `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | | `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `GeneratedClosedSetAttribute`, `IValueObjectVisitor`, `IClosedSetVisitor`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan`, `GeneratedClosedSets`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | -| `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`; the plugins, internal | -| `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem`, `FaultResponseOptions`, `IFaultFeature` | -| `Microsoft.AspNetCore.Http` | `ToHttpResult()`, beside ASP.NET's own `Results` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`, `ClosedSetConverter`; the plugins, internal | +| `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem`, `FaultResponse`, `FaultResponseOptions`, `FaultStatusAttribute`, `IFaultFeature` | +| `Microsoft.AspNetCore.Http` | `ToHttpResult()`, for a `Result` and for a closed set's fault (`TypeKitHttpResultExtensions`, `TypeKitClosedSetHttpResultExtensions`), beside ASP.NET's own `Results` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `Microsoft.AspNetCore.Builder` | `WithFaultStatus()`, beside `WithMetadata` and the other endpoint conventions ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | | `CodoMetis.TypeKit.Serilog` | the destructuring policy, internal | | `Serilog` | `WithTypeKit()`, beside Serilog's own configuration methods ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | | `CodoMetis.TypeKit.Testing` | the assertions and `TypeKitAssertionException` | -| `Microsoft.EntityFrameworkCore` | `UseTypeKit()`, beside the providers' `UseNpgsql` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | +| `Microsoft.EntityFrameworkCore` | `UseTypeKit()`, beside the providers' `UseNpgsql`, and `FirstOrNoneAsync()`/`SingleOrNoneAsync()`, beside EF's `FirstOrDefaultAsync` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | | `Microsoft.Extensions.DependencyInjection` | `AddEntityFrameworkTypeKit()`, `OpenApiOptions.AddTypeKit()` and `AddFaultResponses()`, beside `AddEntityFrameworkNpgsql` and `AddOpenApi` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | The internal aspects live in `CodoMetis.TypeKit.Generators`. Users never type that namespace. diff --git a/docs/logging-and-secrets.md b/docs/logging-and-secrets.md index fe47937..7ade620 100644 --- a/docs/logging-and-secrets.md +++ b/docs/logging-and-secrets.md @@ -108,4 +108,7 @@ the literal, since the literal is the expression. ASP.NET's own refusal of a rou quotes the input, as it does for a `Guid`: in MVC's model-state message ("The value '…' is not valid."), which reaches the response, and in a minimal API's log entry and developer exception page. A body refused by the JSON converter does not -([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)). +([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)), with one exception that is +System.Text.Json's: a value object used as a dictionary key is read from the property name, and the +`JsonException` for a refused or unreadable key carries the key in its `Path` (`$.items.SE`), which +MVC uses as the error's name in its 400. Keep a secret out of a dictionary's keys. diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md index 5414892..3ceca44 100644 --- a/docs/migrating-to-2.md +++ b/docs/migrating-to-2.md @@ -19,10 +19,18 @@ included. ## A dropped Result is a build error CMTK0003, a `Result` or `Option` a call returns and nobody reads, was a warning in 1.x and is an -error in 2.0. It also reports what 1.x missed: `Wait()` on a task of a result, tasks of results -passed to `Task.WaitAll` or `Task.WaitAny`, a `foreach` or `await foreach` that never reads the -results it walks, and a lazy sequence of results turned into a list and dropped. A build that -treated warnings as errors already failed on every 1.x form; only the new forms are new errors. +error in 2.0. It also reports what 1.x missed: + +- `Wait()` on a task of a result, with a timeout too, even where the `bool` that form answers is read; +- tasks of results gathered into an array or a sequence and passed to `Task.WaitAll`; +- a `foreach` or `await foreach` that never reads the results it walks; +- a lazy sequence of results turned into a list and dropped; +- a task picked by `?:`, a `switch` or `??` and awaited, its result never read; +- a tuple or a dictionary holding results, dropped. + +Tasks passed to `Task.WaitAll` or `Task.WaitAny` as separate arguments were reported by 1.x already. +A build that treated warnings as errors already failed on every 1.x form; only the new forms are new +errors. ```csharp await orders.CancelAsync(id); // CMTK0003: an error since 2.0 @@ -33,7 +41,8 @@ await foreach (var _ in orders.CancelEachAsync(ids)) { } // a loop variable ``` Every report is answered in the code and never needs a suppression: `_ =` for a drop nothing -depends on, a cast to `Task` for a task converted on purpose, a loop variable named `_`. To migrate +depends on, a cast to `Task` for a task converted on purpose (`((Task)orders.CancelAsync(id)).Wait(timeout)` +waits without the result), a loop variable named `_`. To migrate a large codebase in steps, lower the rule for a while: ```ini @@ -45,6 +54,57 @@ CMTK0010, CMTK0011 and CMTK0012 are new errors too, but each reports only on wha type-parameter attributes, `[RequireParameterlessConstructor]` and a closed set's `Match`. No code that built against 1.5 meets them. +## An abstract value object is a build error + +In 1.5, an abstract type that declared a value-object marker built: it generated nothing of its own, +its sealed derived types were generated, and a value held as the abstract type was written to JSON as +`{}`. In 2.0 the declaration is CMTK1022. Declare the marker on the sealed type, and keep the abstract +base for what the types share: + +```csharp +// public abstract partial record Money : IValue; 1.5 built these; 2.0: CMTK1022 +// public sealed partial record Euro : Money; + +public abstract record Money; +public sealed partial record Euro : Money, IValue; // the marker on the sealed type, which is generated +``` + +## Queries and documents that ran wrongly now throw + +Three things that ran in 1.5 and answered wrongly throw in 2.0: + +- **`.Value` on a column with a converter of the application's own.** `.Value`, `GetValue()` and + `ValueOrNull()` queried the representation TypeKit's converter would have stored, not the one the + application's `HasConversion` stores, and answered wrong rows; EF now refuses the query. Map the + property with TypeKit's converter, or compare the value object itself. +- **Ordering by a hand-written `CompareTo`.** A query that compares, orders, or takes `Min` or `Max` of + a value object that declares its own `CompareTo` was ordered by the column, the opposite of the + in-memory order for a reversed one; it is refused now, in a query filter too. Order by `.Value` + where the column's order is meant ([EF Core](ef-core.md#queries)). +- **A component name two types claim.** A document in which a nested value object's or closed set's + component name, `Order.Status` as `OrderStatus`, is also another type's described both as one; it + throws now, naming both. Rename one, or give one its own name in `CreateSchemaReferenceId` + ([the ASP.NET Core README](../src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says)). + +## Rebuild libraries and compiled models + +A library whose value objects 1.5's generators wove lacks what 2.0 reads at run time, such as the +mark the hand-written-ordering refusal looks for, so the refusal does not reach its value objects +until the library is rebuilt against 2.0. A compiled model generated with 1.5 keeps 1.5's decisions, +the key generation below among them. Rebuild every project that references +`CodoMetis.TypeKit.Generators`, and regenerate a compiled model (`dotnet ef dbcontext optimize`). + +## Messages that changed + +A test that asserts an exception or a message meets these: + +- An asynchronous combinator given a delegate that returns no task throws an `ArgumentNullException` + naming the delegate, where the `await` threw a `NullReferenceException`. +- A `CodoMetis.TypeKit.Testing` failure, and a fault mapping's refusal, name a nested type after its + containing types (`Result`, `Api.OrderFault`), where 1.5 gave the type's name + alone. +- CMTK0009's message names `SingleOrNone` and `ElementAtOrNone` beside the other replacements. + ## A query's FirstOrNone runs in the database In 1.5, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` on an EF Core query bound to diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs index fa5f564..b413f34 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/MigratingSamples.cs @@ -7,6 +7,14 @@ namespace ReadmeSamples.Docs.Migrating; public enum OrderFault { Unknown } +// sample: migrating-to-2/abstract +// public abstract partial record Money : IValue; 1.5 built these; 2.0: CMTK1022 +// public sealed partial record Euro : Money; + +public abstract record Money; +public sealed partial record Euro : Money, IValue; // the marker on the sealed type, which is generated +// end sample + public interface IOrders { Task> CancelAsync(OrderId id); From 8af8136b53de6358450ab31bc9eb699e5da77646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:52:42 +0200 Subject: [PATCH 077/147] fix: the problem schema a fault response refers to describes fault, as decision 49 says The document's ProblemDetails component had only ASP.NET's members, so a client generated from it never saw fault, the member a fault response is for. The operation transformer now gives the problem schema the fault responses refer to an optional fault string, in OpenAPI 3.1 and 3.0, inline where the host inlines it; its description says which form the host writes (QualifiedFaultNames) and that only a problem answering a fault carries it. A fault another transformer already described is kept, and a document without a fault response stays ASP.NET's, byte for byte. Co-Authored-By: Claude Opus 5.5 --- design/openapi.md | 9 +++ docs/aspnet-core.md | 3 + .../FaultProblem.cs | 6 +- .../FaultResponsesOperationTransformer.cs | 50 ++++++++++-- src/CodoMetis.TypeKit.AspNetCore/README.md | 10 ++- .../TypeKitOpenApiOptionsExtensions.cs | 3 +- .../FaultResponseTests.cs | 81 +++++++++++++++++-- .../Docs/AspNetCoreSamples.cs | 1 + 8 files changed, 142 insertions(+), 21 deletions(-) diff --git a/design/openapi.md b/design/openapi.md index 6ed16ff..4500592 100644 --- a/design/openapi.md +++ b/design/openapi.md @@ -37,6 +37,15 @@ position, and the change lands in the hoisted component. a name the host's own delegate chose, or `null` for an inlined value object, is kept. A name that is also another type's, `Order.Status` beside a top-level `OrderStatus`, refuses the document ([decision 55](adr/0055-component-name-claimed-twice-refused.md)). +- **Fault responses** ([decision 49](adr/0049-fault-responses-client-can-act.md)): an operation + transformer adds a problem response per status an endpoint answers its fault type with, referring to + the problem schema the host's `CreateSchemaReferenceId` names (the shared `ProblemDetails` + component, or an inline schema). That schema gains an optional `fault` string, the member a client + matches on, whose description says which form the host writes (`QualifiedFaultNames`) and that only + a problem answering a fault carries it; what a schema already says of `fault` is kept. ASP.NET's own + problem responses refer to the same component (measured, 10.0.12: whichever endpoint comes first, + and a second document without a fault response keeps it bare); an inline schema of theirs is another + instance and stays bare. A document without a fault response is ASP.NET's, byte for byte. - **Known gaps, documented rather than fixed:** a validation attribute on a value-object property lands in the shared component, as ASP.NET does for any referenced schema (rules belong in `Create`); a property absent from a request body stays `default`, since System.Text.Json calls no converter diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md index 7d4e970..5eb53fa 100644 --- a/docs/aspnet-core.md +++ b/docs/aspnet-core.md @@ -165,6 +165,9 @@ operation that returns the fault type: "409": { "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } } } } } } ``` +The `ProblemDetails` component those responses refer to describes `fault` too, as an optional string, +so a client generated from the document has the member it matches on. + A rule belongs in `Create`, not on the property: a validation attribute on a value-object property lands in the value object's shared component and then applies to every other use. diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs index 1dab920..d273e0c 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs @@ -20,8 +20,8 @@ namespace CodoMetis.TypeKit.AspNetCore; /// /// /// The endpoint's OpenAPI operation lists a problem response for every status the fault type is -/// answered with (AddTypeKit()), and a host with an endpoint whose fault type is not mapped does -/// not start. The problem names the fault, and a type and title where the mapping gives +/// answered with (AddTypeKit()), whose schema describes fault as an optional string, +/// and a host with an endpoint whose fault type is not mapped does not start. The problem names the fault, and a type and title where the mapping gives /// them; its detail is the occurrence's (), or else a fault set /// member's description, and nothing else of the request reaches it. /// @@ -65,7 +65,7 @@ await TypedResults.Problem( type: answer.Type?.ToString(), title: answer.Title, detail: detail, - extensions: new Dictionary { ["fault"] = name }) + extensions: new Dictionary { [FaultResponsesOperationTransformer.FaultMember] = name }) .ExecuteAsync(httpContext); } finally diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs index 1c277f2..0a6a2cc 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesOperationTransformer.cs @@ -21,11 +21,24 @@ namespace CodoMetis.TypeKit.AspNetCore; /// host's names, and referred to (finding 6). A /// response the operation already has is kept, which also makes a second AddTypeKit() a no-op. /// A response's description is the status's reason phrase, followed by the problem types of the -/// members answered with it where the mapping gives any; fault stays a string on the shared -/// problem schema, since an enum of every fault type would change every operation when one is added. +/// members answered with it where the mapping gives any. The problem schema those responses refer to +/// gains an optional fault string, the member a client of a fault response matches on: on the +/// shared component, which ASP.NET's own problem responses refer to as well, so its description says +/// which problems carry it. A string, since an enum of every fault type would change every operation +/// when one is added. A document without a fault response is left as ASP.NET writes it. /// internal sealed class FaultResponsesOperationTransformer : IOpenApiOperationTransformer { + /// The problem's extension member writes. + internal const string FaultMember = "fault"; + + /// What fault holds, as the host writes it: the member's name, or its qualified name. + internal static string FaultDescription(bool qualified) => + (qualified + ? "The fault this problem reports: the name of its member, qualified with its type's (Type.Member, a nested type after its nesting chain)." + : "The fault this problem reports: the name of its member.") + + " Present only on a problem that answers a fault of the endpoint's Result."; + public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { IOpenApiSchema? problem = null; @@ -61,15 +74,38 @@ private static async Task ProblemSchemaAsync(OpenApiOperationTra { var schema = await context.GetOrCreateSchemaAsync(typeof(ProblemDetails), parameterDescription: null, cancellationToken); - var json = context.ApplicationServices.GetRequiredService>().Value.SerializerOptions; - var id = context.ApplicationServices.GetRequiredService>().Get(context.DocumentName) - .CreateSchemaReferenceId(json.GetTypeInfo(typeof(ProblemDetails))); + var services = context.ApplicationServices; + var json = services.GetRequiredService>().Value.SerializerOptions; + var id = services.GetRequiredService>().Get(context.DocumentName) + .CreateSchemaReferenceId(json.GetTypeInfo(typeof(ProblemDetails))); + var qualified = services.GetService>()?.Value.QualifiedFaultNames == true; // A host that inlines it gets it inline, as ASP.NET writes its own problem responses there. - if (id is null || context.Document is not { } document) return schema; + if (id is null || context.Document is not { } document) return WithFault(schema, qualified); - if (document.Components?.Schemas?.ContainsKey(id) != true) document.AddComponent(id, schema); + if (document.Components?.Schemas?.TryGetValue(id, out var component) != true || component is null) + { + document.AddComponent(id, schema); + component = schema; + } + WithFault(component, qualified); return new OpenApiSchemaReference(id, document); } + + /// + /// Describes fault on the problem schema, beside what ASP.NET describes of + /// , where nothing describes it yet: a fault another transformer + /// already gave the schema is kept, and a second fault response finds it there. + /// + private static IOpenApiSchema WithFault(IOpenApiSchema schema, bool qualified) + { + if (schema is OpenApiSchema problem && problem.Properties?.ContainsKey(FaultMember) != true) + { + problem.Properties ??= new Dictionary(StringComparer.Ordinal); + problem.Properties[FaultMember] = new OpenApiSchema { Type = JsonSchemaType.String, Description = FaultDescription(qualified) }; + } + + return schema; + } } diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index f140931..29c545f 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -302,8 +302,14 @@ builder.Services.Configure(options => options.QualifiedFau `QualifiedFaultNames` writes `fault` with its type, so two fault types that share a member name stay apart in one client; the default is the bare name. A nested type is qualified after its nesting chain, an enum as a closed set's `QualifiedName` is, so `Billing.Fault` and `Shipping.Fault` give -`Billing.Fault.NotFound` and `Shipping.Fault.NotFound`. `fault` stays a string on the document's shared -problem schema, since an enum of every fault type would change every operation when one is added. +`Billing.Fault.NotFound` and `Shipping.Fault.NotFound`. + +The document describes `fault` as an optional string on the problem schema the fault responses refer +to, so a generated client has the member to match on; its description says which of the two forms +this host writes. That schema is the shared `ProblemDetails` component, which ASP.NET's own problem +responses refer to as well, so the description also says that only a problem answering a fault +carries it. It stays a string, since an enum of every fault type would change every operation when +one is added. A document without a fault response keeps the schema as ASP.NET writes it. ## NodaTime diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs index 2a76aec..88c3c79 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitOpenApiOptionsExtensions.cs @@ -27,7 +27,8 @@ public static class TypeKitOpenApiOptionsExtensions /// /// /// An endpoint that returns ToHttpResult() also lists a problem-details response for every - /// status its fault type is mapped to with AddFaultResponses. + /// status its fault type is mapped to with AddFaultResponses, and the problem schema those + /// responses refer to describes the fault member. /// /// /// A nested value object's component is named after its nesting chain (Shop.Id is diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs index 72109a2..7b8b254 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponseTests.cs @@ -297,6 +297,10 @@ public async Task The_hosts_problem_details_customization_applies() body["fault"]!.GetValue().ShouldBe("NotFound"); } + /// + /// Inline, as ProducesProblem writes it, with fault beside ASP.NET's members; ProducesProblem's + /// own inline schema is another instance and stays without it. + /// [Fact] public async Task A_host_that_inlines_problem_details_gets_them_inline_as_ProducesProblem_does() { @@ -305,13 +309,66 @@ public async Task A_host_that_inlines_problem_details_gets_them_inline_as_Produc var document = await DocumentAsync(app); var responses = Responses(document, "/orders/{id}", "get"); - var expected = Responses(document, "/control/orders/{id}", "get"); + var control = Responses(document, "/control/orders/{id}", "get")["404"]!; + ProblemSchema(control)["properties"]!.AsObject().ContainsKey("fault").ShouldBeFalse("ASP.NET's own problem response is left as it writes it"); - responses["404"]!["content"]!["application/problem+json"]!["schema"]!["$ref"].ShouldBeNull(); - JsonNode.DeepEquals(responses["404"], expected["404"]).ShouldBeTrue(responses["404"]!.ToJsonString()); + var expected = control.DeepClone(); + ProblemSchema(expected)["properties"]!["fault"] = new JsonObject + { + ["type"] = "string", + ["description"] = FaultResponsesOperationTransformer.FaultDescription(qualified: false), + }; + + ProblemSchema(responses["404"]!)["$ref"].ShouldBeNull(); + JsonNode.DeepEquals(responses["404"], expected).ShouldBeTrue(responses["404"]!.ToJsonString()); document["components"]!["schemas"]!["ProblemDetails"].ShouldBeNull(); } + /// + /// The member a client of a fault response matches on is in the schema the response refers to + /// (decision 49): an optional string whose description says what it holds, as this host writes it. + /// + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1, false)] + [InlineData(OpenApiSpecVersion.OpenApi3_0, false)] + [InlineData(OpenApiSpecVersion.OpenApi3_1, true)] + [InlineData(OpenApiSpecVersion.OpenApi3_0, true)] + public async Task The_problem_schema_describes_fault_as_an_optional_string(OpenApiSpecVersion version, bool qualified) + { + await using var app = await StartAsync(version: version, services: services => + { + MapOrders(services); + if (qualified) services.Configure(options => options.QualifiedFaultNames = true); + }); + var document = await DocumentAsync(app); + + ProblemSchema(Responses(document, "/orders/{id}", "get")["404"]!)["$ref"]!.GetValue().ShouldBe("#/components/schemas/ProblemDetails"); + var problem = document["components"]!["schemas"]!["ProblemDetails"]!; + var fault = problem["properties"]!["fault"].ShouldNotBeNull($"ProblemDetails has no fault: {problem.ToJsonString()}"); + + fault.AsObject().Select(keyword => keyword.Key).Order().ShouldBe(["description", "type"]); + fault["type"]!.ToJsonString().ShouldBe("\"string\"", "never null where it is present, in 3.1 too"); + fault["description"]!.GetValue().ShouldStartWith(qualified + ? "The fault this problem reports: the name of its member, qualified with its type's" + : "The fault this problem reports: the name of its member. "); + (problem["required"]?.AsArray().Select(name => name!.GetValue()) ?? []).ShouldNotContain("fault"); + problem["properties"]!["status"].ShouldNotBeNull("ASP.NET's members stay"); + } + + /// A host whose document has no fault response keeps ASP.NET's problem schema, and the whole document, byte for byte. + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + public async Task A_document_without_a_fault_response_is_as_ASP_NET_writes_it(OpenApiSpecVersion version) + { + await using var withTypeKit = await StartAsync(version: version, orders: false); + await using var without = await StartAsync(version: version, orders: false, typeKit: false, services: _ => { }); + + var json = await withTypeKit.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + json.ShouldBe(await without.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken)); + JsonNode.Parse(json)!["components"]!["schemas"]!["ProblemDetails"]!["properties"]!.AsObject().ContainsKey("fault").ShouldBeFalse(); + } + [Fact] public async Task A_response_the_operation_already_has_is_kept() { @@ -362,7 +419,9 @@ private static async Task StartAsync( bool mvc = false, Action? endpoints = null, bool map = true, - bool controls = true) + bool controls = true, + bool orders = true, + bool typeKit = true) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); builder.WebHost.UseTestServer(); @@ -370,7 +429,7 @@ private static async Task StartAsync( builder.Services.AddOpenApi(options => { options.OpenApiVersion = version; - options.AddTypeKit(); + if (typeKit) options.AddTypeKit(); openApi?.Invoke(options); }); if (services is not null) services(builder.Services); @@ -387,9 +446,13 @@ private static async Task StartAsync( var app = builder.Build(); app.MapOpenApi(); - app.MapGet("/orders/{id:int}", (int id) => Find(id).ToHttpResult()); - app.MapGet("/orders-async/{id:int}", async (int id) => (await Task.FromResult(Find(id))).ToHttpResult()); - app.MapDelete("/orders/{id:int}", (int id) => Cancel(id).ToHttpResult()); + if (orders) + { + app.MapGet("/orders/{id:int}", (int id) => Find(id).ToHttpResult()); + app.MapGet("/orders-async/{id:int}", async (int id) => (await Task.FromResult(Find(id))).ToHttpResult()); + app.MapDelete("/orders/{id:int}", (int id) => Cancel(id).ToHttpResult()); + } + if (controls) MapControls(app); if (mvc) app.MapControllers(); endpoints?.Invoke(app); @@ -430,6 +493,8 @@ private static async Task DocumentAsync(WebApplication app) => private static JsonObject Responses(JsonNode document, string path, string method) => document["paths"]![path]![method]!["responses"]!.AsObject(); + private static JsonNode ProblemSchema(JsonNode response) => response["content"]!["application/problem+json"]!["schema"]!; + private static string ReadmePath() { for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) diff --git a/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs index 643d401..c1dfb2b 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs @@ -234,6 +234,7 @@ public async Task The_document_says_what_the_page_shows() foreach (var (path, item) in shown) document["paths"]![path].ShouldContainSchema(item!, path); document.Component("CustomerId").ShouldContainSchema(JsonNode.Parse("""{"type":"string","format":"uuid"}""")!, "CustomerId"); + document.Component("ProblemDetails")["properties"].ShouldContainSchema(JsonNode.Parse("""{"fault":{"type":"string"}}""")!, "ProblemDetails, which the page says describes fault"); var page = document["paths"]!["/customers"]!["get"]!["parameters"]!.AsArray().Single(parameter => parameter!["name"]!.GetValue() == "page"); page!["schema"]!["type"]!.ToJsonString().ShouldContain("integer", Case.Sensitive, "a query parameter is described as a parameter of the wrapped type, inline"); } From 3ec47120550eabdd82cc1391f23db547c27dae68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:58:40 +0200 Subject: [PATCH 078/147] docs: a struct value object missing from an MVC query, header or form reaches the action as default MVC binds nothing for a missing value of a non-nullable struct and calls the action with default, the model state valid, as for a Guid or an int; for a struct value object that is an instance no factory made. A minimal API answers 400, and MVC already requires a class value object or a closed set. The README's Binding section and docs/aspnet-core.md now say so beside the missing body property, with [BindRequired] (MVC answers 400) or a nullable parameter as the remedy, each as compiled sample code; MvcBindingTests pins the hazard, the controls and the remedy. Co-Authored-By: Claude Opus 5.5 --- design/openapi.md | 5 +- docs/aspnet-core.md | 13 +- src/CodoMetis.TypeKit.AspNetCore/README.md | 14 ++ .../MvcBindingTests.cs | 130 ++++++++++++++++++ .../AspNetCore/AspNetCoreSamples.cs | 75 ++++++++++ .../Docs/AspNetCoreSamples.cs | 9 ++ 6 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 test/CodoMetis.TypeKit.AspNetCore.Tests/MvcBindingTests.cs diff --git a/design/openapi.md b/design/openapi.md index 4500592..802f9fd 100644 --- a/design/openapi.md +++ b/design/openapi.md @@ -49,7 +49,10 @@ position, and the change lands in the hoisted component. - **Known gaps, documented rather than fixed:** a validation attribute on a value-object property lands in the shared component, as ASP.NET does for any referenced schema (rules belong in `Create`); a property absent from a request body stays `default`, since System.Text.Json calls no converter - for it (`required`, `RespectRequiredConstructorParameters`); a minimal API answers a refused body + for it (`required`, `RespectRequiredConstructorParameters`); a query, header or form value absent + from an MVC request reaches the action as `default` for a struct value object, MVC's rule for any + non-nullable struct, where a minimal API answers 400 (`[BindRequired]`, or a nullable parameter; + measured 2026-10-13, MvcBindingTests); a minimal API answers a refused body with an empty 400; MVC's own binding message quotes the input, as it does for a `Guid`. - **ASP0020** ([decision 12](adr/0012-asp0020-documented-suppressed.md)): a minimal-API route parameter whose value object is declared in the same project fails the build, because the route analyzer reads the source before Metalama weaves diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md index 5eb53fa..3f6e9ef 100644 --- a/docs/aspnet-core.md +++ b/docs/aspnet-core.md @@ -79,12 +79,20 @@ generators add `IParsable`; the binding is right, and the README shows the pragm | `GET /customers?page=500` | 400: `Create` refused it, through `TryParse` | | `POST /customers` with `"email": "nobody"` | 400: the `JsonException` names `Email` and `NoAt`, never the text, and is logged; the body is empty unless the host adds `UseStatusCodePages()` | | `POST /customers` without `email` | 200 with a default `Email`, unless `RespectRequiredConstructorParameters` is set, then 400 | +| `GET /mvc/customers` without `page` | 400, since the action marks `page` `[BindRequired]`; without it, the action gets a default `PageSize` | -The last row is the one to act on: System.Text.Json calls no converter for a property the body +The last two rows are the ones to act on. System.Text.Json calls no converter for a property the body lacks, so it stays `default`, an instance that passed no factory. The README's [Binding](../src/CodoMetis.TypeKit.AspNetCore/README.md#binding) shows the two lines that make both minimal APIs and MVC answer 400 instead. +MVC does the same with a query, header or form value the request lacks: for a non-nullable struct, a +struct value object as much as a `Guid` or an `int`, it binds nothing, keeps the model state valid +and calls the action with `default`. A minimal API answers 400, and MVC already requires a closed set +or a class value object, as any non-nullable reference type. Mark a struct value object's parameter +`[BindRequired]` (`Microsoft.AspNetCore.Mvc.ModelBinding`), as the controller below does for `page`, +and MVC answers 400; or make it nullable where it is optional. + ## Binding in MVC ```csharp @@ -95,6 +103,9 @@ public sealed class CustomersController(ICustomers customers) : ControllerBase [HttpGet("{id}")] public Results, FaultProblem> Get([FromRoute] CustomerId id) => customers.Find(id).ToHttpResult(); // the generated TypeConverter binds it + [HttpGet] + public IReadOnlyList List([FromQuery] Email? email, [FromQuery, BindRequired] PageSize page) => customers.List(email, page); // without [BindRequired], no page is a default PageSize + [HttpPost] public Results, FaultProblem> Post(RegisterCustomer body) => customers.Register(body).ToHttpResult(); } diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 29c545f..bc400e5 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -382,3 +382,17 @@ The generated members cover the request itself: Both minimal APIs and MVC then answer a body without it with 400. An optional constructor parameter (`Discount? Discount`) then needs a default value (`= null`). +- **A query, header or form value missing from an MVC request is not refused either.** MVC binds + nothing for a non-nullable struct and calls the action with `default`, as it does for a `Guid` or + an `int`, and the model state stays valid; for a struct value object that is again an instance + that passed no factory. A minimal API answers 400, and MVC already requires a class value object + or a closed set, as it does any non-nullable reference type. Mark the parameter `[BindRequired]`, + and MVC answers 400 ("A value for the 'id' parameter or property was not provided."), or make it + nullable where it is optional, and the action receives `null`: + + ```csharp + using Microsoft.AspNetCore.Mvc.ModelBinding; // BindRequired + + [HttpGet("/mvc/orders")] + public OrderDto Find([FromQuery, BindRequired] OrderId id, [FromQuery] Quantity? limit) => orders.Find(id, limit); + ``` diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/MvcBindingTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/MvcBindingTests.cs new file mode 100644 index 0000000..acdf829 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/MvcBindingTests.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Reflection; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A query, header or form value missing from an MVC request reaches the action as default for a +/// struct value object, an instance no factory made, with the model state valid: MVC's rule for any +/// non-nullable struct, as for a or an . A minimal API answers 400, +/// and MVC already requires a class value object or a closed set, as any non-nullable reference type. +/// The README's Binding section and docs/aspnet-core.md say so, with the remedy pinned here: +/// [BindRequired] answers 400, and a nullable parameter receives . +/// +public sealed class MvcBindingTests +{ + private const string Default = "default, model state valid"; + + [Theory] + // The hazard: MVC binds nothing and calls the action. + [InlineData("GET", "/mvc/query", HttpStatusCode.OK, Default)] + [InlineData("GET", "/mvc/header", HttpStatusCode.OK, Default)] + [InlineData("POST", "/mvc/form", HttpStatusCode.OK, Default)] + [InlineData("GET", "/mvc/wrapped", HttpStatusCode.OK, Default)] // the wrapped types alike: MVC's rule, not the value object's + // Already refused: a class value object and a closed set in MVC, non-nullable reference types, and a minimal API. + [InlineData("GET", "/mvc/class", HttpStatusCode.BadRequest, null)] + [InlineData("GET", "/mvc/set", HttpStatusCode.BadRequest, null)] + [InlineData("GET", "/minimal/query", HttpStatusCode.BadRequest, null)] + // The remedy: [BindRequired], or a nullable parameter. + [InlineData("GET", "/mvc/required/query", HttpStatusCode.BadRequest, "A value for the 'count' parameter or property was not provided.")] + [InlineData("GET", "/mvc/required/header", HttpStatusCode.BadRequest, "A value for the 'X-Id' parameter or property was not provided.")] + [InlineData("POST", "/mvc/required/form", HttpStatusCode.BadRequest, "A value for the 'count' parameter or property was not provided.")] + [InlineData("GET", "/mvc/required/query?count=3", HttpStatusCode.OK, "3")] + [InlineData("GET", "/mvc/nullable", HttpStatusCode.OK, "null")] + public async Task A_missing_value_reaches_an_MVC_action_as_default_unless_it_is_required_or_nullable(string method, string path, HttpStatusCode status, string? body) + { + await using var app = await StartAsync(); + + using var request = new HttpRequestMessage(new HttpMethod(method), path); + if (method == "POST") request.Content = new FormUrlEncodedContent([]); + using var response = await app.GetTestClient().SendAsync(request, TestContext.Current.CancellationToken); + var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(status, $"{method} {path}: {text}"); + if (body is not null) text.ShouldContain(body); + } + + private static async Task StartAsync() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyControllers(typeof(BindingController))); + }); + + var app = builder.Build(); + app.MapControllers(); + app.MapGet("/minimal/query", ([FromQuery] ProbeCount count) => count.Value.ToString(CultureInfo.InvariantCulture)); + + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return app; + } + + private sealed class OnlyControllers(params Type[] controllers) : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) + { + foreach (var controller in controllers) feature.Controllers.Add(controller.GetTypeInfo()); + } + } + + [ApiController] + public sealed class BindingController : ControllerBase + { + [HttpGet("/mvc/query")] + public string Query([FromQuery] ProbeCode code, [FromQuery] ProbeCount count) => Seen(code.Value is null && count.Value == 0); + + [HttpGet("/mvc/header")] + public string Header([FromHeader(Name = "X-Id")] ProbeId id) => Seen(id.Value == Guid.Empty); + + [HttpPost("/mvc/form")] + public string Form([FromForm] ProbeCount count) => Seen(count.Value == 0); + + [HttpGet("/mvc/wrapped")] + public string Wrapped([FromQuery] Guid id, [FromQuery] int count) => Seen(id == Guid.Empty && count == 0); + + [HttpGet("/mvc/class")] + public string Class([FromQuery] ProbeLabel label) => label.Value; + + [HttpGet("/mvc/set")] + public string Set([FromQuery] ProbeStatus status) => status.ToString(); + + [HttpGet("/mvc/required/query")] + public string RequiredQuery([FromQuery, BindRequired] ProbeCount count) => count.Value.ToString(CultureInfo.InvariantCulture); + + [HttpGet("/mvc/required/header")] + public string RequiredHeader([FromHeader(Name = "X-Id"), BindRequired] ProbeId id) => id.Value.ToString(); + + [HttpPost("/mvc/required/form")] + public string RequiredForm([FromForm, BindRequired] ProbeCount count) => count.Value.ToString(CultureInfo.InvariantCulture); + + [HttpGet("/mvc/nullable")] + public string Nullable([FromQuery] ProbeCount? count) => count is { } given ? given.Value.ToString(CultureInfo.InvariantCulture) : "null"; + + private string Seen(bool isDefault) => isDefault && ModelState.IsValid ? Default : "bound"; + } +} diff --git a/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs index 727e19c..7f1a30c 100644 --- a/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/AspNetCore/AspNetCoreSamples.cs @@ -6,6 +6,10 @@ // sample: CodoMetis.TypeKit.AspNetCore/every-document using Microsoft.AspNetCore.OpenApi; // OpenApiOptions +// end sample +// sample: CodoMetis.TypeKit.AspNetCore/bind-required +using Microsoft.AspNetCore.Mvc.ModelBinding; // BindRequired + // end sample using System.Net; using System.Text; @@ -18,6 +22,8 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -197,6 +203,75 @@ public async Task A_body_without_a_value_object_is_refused_only_with_the_option( } } +// Internal, so no host of this assembly discovers it unasked; the test below adds it. +[ApiController] +internal sealed class FindOrderController(IOrders orders) : ControllerBase +{ + // sample: CodoMetis.TypeKit.AspNetCore/bind-required + [HttpGet("/mvc/orders")] + public OrderDto Find([FromQuery, BindRequired] OrderId id, [FromQuery] Quantity? limit) => orders.Find(id, limit); + // end sample +} + +/// +/// What the README's binding section says about a value missing from an MVC query: with [BindRequired] +/// a 400 naming the parameter, and a nullable parameter null. MvcBindingTests in the AspNetCore tests +/// pins the default MVC binds without either. +/// +public sealed class BindRequiredTests +{ + [Fact] + public async Task A_required_value_object_missing_from_the_query_is_a_400_and_an_optional_one_is_null() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + var orders = new RecordingOrders(); + builder.Services.AddSingleton(orders); + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyFindOrder()); + }); + + await using var app = builder.Build(); + app.MapControllers(); + await app.StartAsync(TestContext.Current.CancellationToken); + var client = app.GetTestClient(); + + using var missing = await client.GetAsync("/mvc/orders", TestContext.Current.CancellationToken); + missing.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await missing.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldContain("A value for the 'id' parameter or property was not provided."); + orders.Calls.ShouldBe(0, "the action never ran"); + + var id = Guid.NewGuid(); + using var found = await client.GetAsync($"/mvc/orders?id={id}", TestContext.Current.CancellationToken); + found.StatusCode.ShouldBe(HttpStatusCode.OK); + orders.Calls.ShouldBe(1); + orders.Limit.ShouldBeNull(); + } + + private sealed class RecordingOrders : IOrders + { + public int Calls { get; private set; } + + public Quantity? Limit { get; private set; } + + public OrderDto Find(OrderId id, Quantity? limit = null) + { + Calls++; + Limit = limit; + return new OrderDto(id, [], []); + } + } + + private sealed class OnlyFindOrder : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) => + feature.Controllers.Add(typeof(FindOrderController).GetTypeInfo()); + } +} + public sealed partial class AspNetCoreTests { private const string Readme = "src/CodoMetis.TypeKit.AspNetCore/README.md"; diff --git a/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs index c1dfb2b..1e4fb9e 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/AspNetCoreSamples.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -64,6 +65,9 @@ public sealed class CustomersController(ICustomers customers) : ControllerBase [HttpGet("{id}")] public Results, FaultProblem> Get([FromRoute] CustomerId id) => customers.Find(id).ToHttpResult(); // the generated TypeConverter binds it + [HttpGet] + public IReadOnlyList List([FromQuery] Email? email, [FromQuery, BindRequired] PageSize page) => customers.List(email, page); // without [BindRequired], no page is a default PageSize + [HttpPost] public Results, FaultProblem> Post(RegisterCustomer body) => customers.Register(body).ToHttpResult(); } @@ -180,6 +184,11 @@ public async Task A_refused_value_answers_as_the_table_says() problem.ShouldContain("Email"); problem.ShouldNotContain("nobody"); (await client.GetAsync($"/mvc/customers/{customers.Ann.Id}", token)).StatusCode.ShouldBe(HttpStatusCode.OK); + + using var unpaged = await client.GetAsync("/mvc/customers", token); + unpaged.StatusCode.ShouldBe(HttpStatusCode.BadRequest, "page is [BindRequired]; without it MVC would call the action with a default PageSize"); + (await unpaged.Content.ReadAsStringAsync(token)).ShouldContain("A value for the 'page' parameter or property was not provided."); + (await client.GetAsync("/mvc/customers?page=50", token)).StatusCode.ShouldBe(HttpStatusCode.OK); } /// The problem the page shows, read from its first JSON block, against what the host answers for an unknown id. From 92e17a4fba6a92b346d43c12ce4188c1d6e3bb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:01:26 +0200 Subject: [PATCH 079/147] test: a group's and a controller's fault status, and a Fault's detail through every overload, are held A route group's WithFaultStatus and a controller's [FaultStatus] reach every endpoint of theirs, and an endpoint's or action's own replaces it, in the response and the document; reading the first status instead of the last answered 410 where the endpoint says 404, with the suite green. A Fault's detail reaches the problem through the command overload and both overloads with the endpoint's own success, not only the valued one; dropping it from those three had kept the suite green too. Co-Authored-By: Claude Opus 5.5 --- .../FaultResponsePartsTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs index a081130..6a67e06 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs @@ -38,6 +38,9 @@ private static Result Rename(int id) => private static Result> RenameWithDetail(int id) => id == 1 ? new Order(1, "renamed") : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.")); + private static Result> ArchiveWithDetail(int id) => + id == 1 ? Result.Success() : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.")); + private static Result Find(int id) => id switch { 1 => new Order(1, "book"), @@ -133,6 +136,34 @@ public async Task A_detail_reaches_the_problem_only_when_there_is_one() detailed["fault"]!.GetValue().ShouldBe("NameTaken", "the mapping stays keyed on the enum"); } + /// + /// Every overload over a carries the occurrence's detail to the problem: + /// the valued one, the command, and each with the endpoint's own success. + /// + [Theory] + [InlineData("PUT", "/detailed/orders/2")] // Result> + [InlineData("DELETE", "/detailed/orders/2")] // Result> + [InlineData("POST", "/detailed/orders/2/copies")] // Result>, with the endpoint's success + [InlineData("POST", "/detailed/orders/2/archive")] // Result>, with the endpoint's success + public async Task An_occurrence_s_detail_reaches_the_problem_through_every_overload(string method, string path) + { + await using var app = await StartAsync(endpoints: app => + { + app.MapDelete("/detailed/orders/{id:int}", (int id) => ArchiveWithDetail(id).ToHttpResult()); + app.MapPost("/detailed/orders/{id:int}/copies", (int id) => RenameWithDetail(id).ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order))); + app.MapPost("/detailed/orders/{id:int}/archive", (int id) => ArchiveWithDetail(id).ToHttpResult(() => TypedResults.Accepted("/jobs/7"))); + }); + + using var request = new HttpRequestMessage(new HttpMethod(method), path); + using var response = await app.GetTestClient().SendAsync(request, TestContext.Current.CancellationToken); + var problem = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict, $"{method} {path}"); + problem["fault"]!.GetValue().ShouldBe("NameTaken"); + problem.AsObject().ContainsKey("detail").ShouldBeTrue($"{method} {path} lost the occurrence's detail: {problem.ToJsonString()}"); + problem["detail"]!.GetValue().ShouldBe("Order 7 is called that."); + } + /// /// A closed set answered by its kinds: 404, 409 and 403 with nothing mapped by hand, and a member's /// description as the problem's detail, where the member has one. @@ -167,6 +198,43 @@ public async Task A_status_per_endpoint_changes_that_endpoint_alone() paths["/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "409"]); } + /// + /// A route group's status reaches every endpoint of it, and an endpoint's own replaces it for that + /// endpoint: the last status the metadata sets, as ASP.NET reads metadata, in the response and the document. + /// + [Fact] + public async Task A_group_s_status_reaches_its_endpoints_and_an_endpoint_s_own_replaces_it() + { + await using var app = await StartAsync(endpoints: app => + { + var archive = app.MapGroup("/archive").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + archive.MapPut("/orders/{id:int}", (int id) => Rename(id).ToHttpResult()); + archive.MapPut("/restored/orders/{id:int}", (int id) => Rename(id).ToHttpResult()).WithFaultStatus(OrderFault.NotFound, StatusCodes.Status404NotFound); + }); + + (await ProblemAsync(app, "/archive/orders/9", HttpStatusCode.Gone))["fault"]!.GetValue().ShouldBe("NotFound"); + await ProblemAsync(app, "/archive/restored/orders/9", HttpStatusCode.NotFound); + await ProblemAsync(app, "/archive/restored/orders/2", HttpStatusCode.Conflict); + + var paths = (await DocumentAsync(app))["paths"]!; + paths["/archive/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "409", "410"]); + paths["/archive/restored/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "409"]); + } + + /// The same for MVC: a controller's attribute reaches every action of it, and an action's own replaces it. + [Fact] + public async Task A_controller_s_status_attribute_reaches_its_actions_and_an_action_s_own_replaces_it() + { + await using var app = await StartAsync(mvc: typeof(ArchiveController)); + + await ProblemAsync(app, "/mvc/archive/orders/9", HttpStatusCode.Gone); + await ProblemAsync(app, "/mvc/archive/restored/orders/9", HttpStatusCode.NotFound); + + var paths = (await DocumentAsync(app))["paths"]!; + paths["/mvc/archive/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "409", "410"]); + paths["/mvc/archive/restored/orders/{id}"]!["put"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "404", "409"]); + } + [Fact] public void A_status_per_endpoint_is_an_error_status() => Should.Throw(() => WebApplication.CreateBuilder().Build().MapGet("/x", () => Rename(1).ToHttpResult()).WithFaultStatus(OrderFault.NameTaken, 200)) @@ -360,6 +428,18 @@ public sealed class RenamesController : ControllerBase public Results, FaultProblem> Rename(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); } + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class ArchiveController : ControllerBase + { + [HttpPut("/mvc/archive/orders/{id:int}")] + public Results, FaultProblem> Rename(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + + [HttpPut("/mvc/archive/restored/orders/{id:int}")] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status404NotFound)] + public Results, FaultProblem> Restore(int id) => FaultResponsePartsTests.Rename(id).ToHttpResult(); + } + [ApiController] public sealed class MisnamedController : ControllerBase { From 05da7ad0d962192e3b94a06b2443c6e4bd93e444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:05:04 +0200 Subject: [PATCH 080/147] docs: MVC's 400 names a refused dictionary key, the one place a refusal's input reaches the client Co-Authored-By: Claude Opus 5.5 --- docs/aspnet-core.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md index 3f6e9ef..08112ee 100644 --- a/docs/aspnet-core.md +++ b/docs/aspnet-core.md @@ -114,7 +114,10 @@ public sealed class CustomersController(ICustomers customers) : ControllerBase MVC binds a route or query value object through the generated `TypeConverter`, and a body through the JSON converter as a minimal API does. The difference is the 400: MVC answers a refused body with its validation problem details, whose message is the `JsonException`'s, so the client reads -`Email refused the JSON value (NoAt)` and never the text. +`Email refused the JSON value (NoAt)` and never the text. The one exception is a value object used +as a dictionary key: System.Text.Json puts the key in the `JsonException`'s `Path`, and MVC names the +error after it (`"$.items.SE"`), so keep a secret out of a dictionary's keys +([Logging and secrets](logging-and-secrets.md#refusals-never-quote-the-input)). ## A Result as a response From 71a4565a035a51b5ff5a9b970918851ccc8f91ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:49:02 +0200 Subject: [PATCH 081/147] fix: a chain of taps on a stored result or task no longer reports CMTK0003 Tap, TapAsync, TapError and TapErrorAsync return their receiver, and a single tap on a local, parameter or field was already silent. The second link of a chain (r.Tap(log).TapError(warn), await pending.TapAsync(a) .TapErrorAsync(b)) has the first link as its receiver, an invocation, which was taken for a fresh result and reported as an Error. A receiver is now held when it is a tap that returned a held receiver, or what a held task completes with, awaited in the chain ((await pending).Tap(log)). A chain that starts at a call, or passes through Map, still reports. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- .../IgnoredOutcomeAnalyzer.cs | 31 ++++++++---- src/CodoMetis.TypeKit.Analyzers/README.md | 4 +- .../IgnoredOutcomeAnalyzerTests.cs | 50 +++++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index bbd9841..5fea399 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -32,7 +32,7 @@ exist. |---|---|---|---| | CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | | CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | -| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | +| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver, and so is a chain of them on one (`r.Tap(log).TapError(warn)`, `(await pending).Tap(log)`), measured as an Error's false report before 2.0.0 was tagged. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | | CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | | CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | | CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index 66aabd2..201b3ff 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -59,8 +59,10 @@ namespace CodoMetis.TypeKit.Analyzers; /// /// Tap, TapAsync, TapError and TapErrorAsync return their receiver unchanged, /// and so do the Task continuations of those names, so result.Tap(log); or -/// await pending.TapAsync(log); on a variable drops nothing the caller does not still hold. On -/// anything else, such as Find(id).Tap(log);, the result is gone and is reported. +/// await pending.TapAsync(log); on a variable drops nothing the caller does not still hold. Nor +/// does a chain of them, result.Tap(log).TapError(warn);, or one on what a held task completes +/// with, (await pending).Tap(log);. On anything else, such as Find(id).Tap(log);, the +/// result is gone and is reported. /// /// /// In a project that declares value objects, Metalama runs analyzers on the source before weaving, @@ -191,7 +193,7 @@ private static IEnumerable Producers(IOperation operation, private static string? DroppedOutcome(ITypeSymbol? type, IInvocationOperation invocation, TypeKitSymbols symbols, Tasks tasks) { if ((symbols.OutcomeName(type) ?? tasks.PendingOutcome(type, symbols)) is { } outcome) - return ReturnsItsStoredReceiver(invocation, symbols) ? null : outcome; + return ReturnsItsStoredReceiver(invocation, symbols, tasks) ? null : outcome; return DroppedHolder(tasks.HolderOutcome(type, symbols) ?? tasks.HolderOutcome(tasks.Awaited(type), symbols), invocation, symbols, tasks); } @@ -222,7 +224,7 @@ private static bool MadeResults(IInvocationOperation invocation, TypeKitSymbols return invocation.Descendants().Any(operation => operation switch { IInvocationOperation call when tasks.PassesOn(call) => false, - IInvocationOperation call => (symbols.OutcomeName(call.Type) ?? tasks.PendingOutcome(call.Type, symbols)) is not null && !ReturnsItsStoredReceiver(call, symbols) + IInvocationOperation call => (symbols.OutcomeName(call.Type) ?? tasks.PendingOutcome(call.Type, symbols)) is not null && !ReturnsItsStoredReceiver(call, symbols, tasks) || tasks.SequenceOutcome(call.Type, symbols) is not null && !Given(call, symbols), IMethodReferenceOperation reference => (symbols.OutcomeName(reference.Method.ReturnType) ?? tasks.PendingOutcome(reference.Method.ReturnType, symbols)) is not null, _ => false @@ -364,26 +366,37 @@ private static IOperation Unwrap(IOperation operation) => /// outcome's own Tap, TapAsync, TapError and TapErrorAsync, and the /// Task continuations TapAsync and TapErrorAsync, which return the task's result as it was. /// - private static bool ReturnsItsStoredReceiver(IInvocationOperation invocation, TypeKitSymbols symbols) + private static bool ReturnsItsStoredReceiver(IInvocationOperation invocation, TypeKitSymbols symbols, Tasks tasks) { var method = invocation.TargetMethod; if (method is { IsStatic: false, Name: "Tap" or "TapAsync" or "TapError" or "TapErrorAsync" } && symbols.OutcomeName(method.ContainingType) is not null) - return IsStored(invocation.Instance); + return IsStored(invocation.Instance, symbols, tasks); // pending.TapAsync(log) binds to the extension block's member, with the task as its instance; // Result.TapAsync(pending, log) to its implementation, with the task as the first argument. if (method.Name is "TapAsync" or "TapErrorAsync" && symbols.IsDeclaredByResultClass(method)) - return IsStored(invocation.Instance ?? invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == 0)?.Value); + return IsStored(invocation.Instance ?? invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == 0)?.Value, symbols, tasks); return false; } - private static bool IsStored(IOperation? receiver) + /// + /// Whether the caller still holds : a local, a parameter or a field; a + /// tap that returned such a receiver, so that r.Tap(log).TapError(warn) holds at every link; + /// or what a task so held completes with, awaited in the chain, (await pending).Tap(log). + /// + private static bool IsStored(IOperation? receiver, TypeKitSymbols symbols, Tasks tasks) { while (receiver is IConversionOperation { IsImplicit: true } conversion) receiver = conversion.Operand; - return receiver is ILocalReferenceOperation or IParameterReferenceOperation or IFieldReferenceOperation; + return receiver switch + { + ILocalReferenceOperation or IParameterReferenceOperation or IFieldReferenceOperation => true, + IInvocationOperation tap => ReturnsItsStoredReceiver(tap, symbols, tasks), + IAwaitOperation { Operation: var awaited } => IsStored(tasks.PassedOn(awaited), symbols, tasks), + _ => false, + }; } /// The task and collection types, resolved once per compilation. diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 01a4b64..6b04dc7 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -204,7 +204,9 @@ a count from a read; enumerate it with a loop that reads each result, or discard `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` return their receiver unchanged, and so do the `Task` continuations `TapAsync` and `TapErrorAsync`, so `result.Tap(log);` and `await pending.TapAsync(log);` -on a variable, parameter or field are silent. `Find(id).Tap(log);` drops the result and is reported. +on a variable, parameter or field are silent, and so is a chain of them, `result.Tap(log).TapError(warn);`, +or one on what a held task completes with, `(await pending).Tap(log);`. `Find(id).Tap(log);` drops +the result and is reported, and so does `Find(id).Tap(log).TapError(warn);`. ## CMTK0004 diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index 043ab1b..aafc3f6 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -286,6 +286,56 @@ public static async Task Run(Service service, Result parameter) Cmtk0003(5, "Result", "TapErrorAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A chain of taps on a stored result or task hands back what the caller still holds, link by + /// link: each Tap returns its receiver, and that receiver is the previous Tap, which + /// returned a stored one. So is a tap on what a held task completes with, awaited in the chain. A + /// chain that starts at a call, or passes through a combinator that makes a new result, drops it. + /// + [Fact] + public Task A_chain_of_taps_on_a_stored_result_or_task_is_silent_and_on_a_call_reports() => + Test( + """ + public static class Caller + { + private static Result _field = 1; + + public static async Task Run(Service service, Result parameter, Result command, Option option, Task> pending) + { + var local = service.Load(); + parameter.Tap(_ => { }).TapError(_ => { }); + local.Tap(_ => { }).Tap(_ => { }).TapError(_ => { }); + _field.TapError(_ => { }).Tap(_ => { }); + command.Tap(() => { }).TapError(_ => { }); + option.Tap(_ => { }).Tap(_ => { }); + await local.TapAsync(_ => Task.CompletedTask).TapErrorAsync(_ => { }); + await pending.TapAsync(_ => { }).TapErrorAsync(_ => { }); + await pending.TapAsync(_ => { }).TapErrorAsync(_ => Task.CompletedTask).ConfigureAwait(false); + await parameter.Tap(_ => { }).TapErrorAsync(_ => Task.CompletedTask); + + var held = await pending; + held.Tap(_ => { }).TapError(_ => { }); + (await pending).Tap(_ => { }).TapError(_ => { }); + (await pending.TapAsync(_ => { })).TapError(_ => { }); + (await pending.TapAsync(_ => { }).ConfigureAwait(false)).TapError(_ => { }); + + {|#0:service.Load().Tap(_ => { }).TapError(_ => { })|}; + {|#1:await service.LoadAsync().TapAsync(_ => { }).TapErrorAsync(_ => { })|}; + {|#2:(await service.LoadAsync().TapAsync(_ => { })).TapError(_ => { })|}; + {|#3:(await service.LoadAsync()).Tap(_ => { })|}; + {|#4:local.Map(x => x + 1).Tap(_ => { })|}; + {|#5:await pending.MapAsync(x => x + 1).TapAsync(_ => { })|}; + } + } + """, + Cmtk0003(0, "Result", "TapError"), + Cmtk0003(1, "Result", "TapErrorAsync"), + Cmtk0003(2, "Result", "TapError"), + Cmtk0003(3, "Result", "Tap"), + Cmtk0003(4, "Result", "Tap"), + Cmtk0003(5, "Result", "TapAsync")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// The Task continuations TapAsync and TapErrorAsync return the task's result /// unchanged, so awaiting them on a task the caller still holds drops nothing, as the instance From 107e5b89cd222ce692f20e726860966eac17a5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:51:04 +0200 Subject: [PATCH 082/147] fix: CMTK0012 reports a positional Match followed by another conditional access s?.Match(() => "a", () => "b")?.ToUpper() was silent, and so were h.Status?.Match(..)?.Trim() and s?.Match(..)?.Result: the receiver was taken from the nearest ?. around the call, which there is the one after it, whose receiver is the call's own result. It is now the ?. whose WhenNotNull holds the call, as CMTK0010 already finds it. The code fix applies to each form. Co-Authored-By: Claude Opus 5.5 --- .../PositionalMatchArgumentAnalyzer.cs | 4 +- .../PositionalMatchArgumentAnalyzerTests.cs | 86 ++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs index 3729b93..72c5f43 100644 --- a/src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/PositionalMatchArgumentAnalyzer.cs @@ -110,8 +110,10 @@ private static void Analyze(SyntaxNodeAnalysisContext context, TypeKitSymbols sy if (model.GetSymbolInfo(access.Expression, cancellationToken).Symbol is ITypeSymbol) return null; return model.GetTypeInfo(access.Expression, cancellationToken).Type as INamedTypeSymbol; + // The ?. that binds the call is the one whose WhenNotNull holds it. In s?.Match(…)?.ToUpper() + // the nearest one around the call is the second, whose receiver is the call itself. case MemberBindingExpressionSyntax binding when binding.Name.Identifier.ValueText == Match: - return invocation.FirstAncestorOrSelf() is { } conditional + return invocation.Ancestors().OfType().FirstOrDefault(conditional => conditional.WhenNotNull.Span.Contains(invocation.Span)) is { } conditional ? model.GetTypeInfo(conditional.Expression, cancellationToken).Type as INamedTypeSymbol : null; diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs index f5bfb36..6e9445c 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/PositionalMatchArgumentAnalyzerTests.cs @@ -73,8 +73,10 @@ private static AnalyzerTest Test(string code, p return test; } - private static DiagnosticResult Cmtk0012(string set, string names) => - new DiagnosticResult("CMTK0012", DiagnosticSeverity.Error).WithLocation(0).WithArguments(set, names); + private static DiagnosticResult Cmtk0012(string set, string names) => Cmtk0012(0, set, names); + + private static DiagnosticResult Cmtk0012(int location, string set, string names) => + new DiagnosticResult("CMTK0012", DiagnosticSeverity.Error).WithLocation(location).WithArguments(set, names); [Fact] public void The_descriptor_is_the_published_contract() @@ -144,6 +146,43 @@ static class Use Cmtk0012("Status", "open:, shipped:, @new:")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A conditional call followed by another conditional access, s?.Match(…)?.ToUpper(): the + /// nearest ?. around the call is the one after it, whose receiver is the call's own result. + /// The receiver is the one whose ?. binds the call, bound or not. + /// + [Fact] + public Task A_conditional_call_followed_by_a_conditional_access_is_reported() => + Test( + """ + sealed class Holder + { + public Subjects.Status Status { get; set; } = Subjects.Status.Open; + } + + static class Use + { + static string Upper(Subjects.Status? s) => s?.Match({|#0:() => "a"|}, () => "b", () => "c")?.ToUpper(); + + static string Trimmed(Holder h) => h.Status?.Match({|#1:() => " a"|}, () => "b", () => "c")?.Trim(); + + static string Awaited(Subjects.Status? s) => s?.Match({|#2:() => System.Threading.Tasks.Task.FromResult("a")|}, () => System.Threading.Tasks.Task.FromResult("b"), () => System.Threading.Tasks.Task.FromResult("c"))?.Result; + + static string Unbound(Subjects.Unwoven? u) => u?{|CS1061:.Match|}({|#3:() => 1|}, () => 2)?.ToString(); + + static string Nested(Holder? h) => h?.Status?.Match({|#4:() => "a"|}, () => "b", () => "c")?.Trim()?.ToUpper(); + + static int? Length(Subjects.Status? s) => s?.Match({|#5:() => "a"|}, () => "b", () => "c").Length; + } + """, + Cmtk0012(0, "Status", "open:, shipped:, @new:"), + Cmtk0012(1, "Status", "open:, shipped:, @new:"), + Cmtk0012(2, "Status", "open:, shipped:, @new:"), + Cmtk0012(3, "Unwoven", "low:, high:"), + Cmtk0012(4, "Status", "open:, shipped:, @new:"), + Cmtk0012(5, "Status", "open:, shipped:, @new:")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// Before weaving, in the project that declares the set, the call does not bind; one argument per /// member on a closed set is the generated Match, and the names are the generators' rule. @@ -209,4 +248,47 @@ static int Pick(Subjects.Status s) => s.Match( ExpectedDiagnostics = { Cmtk0012("Status", "open:, shipped:, @new:") }, } .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task The_fix_names_the_arguments_of_a_conditional_call_followed_by_a_conditional_access() => + new CodeFixTest( + Sets + """ + sealed class Holder + { + public Subjects.Status Status { get; set; } = Subjects.Status.Open; + } + + static class Use + { + static string Upper(Subjects.Status? s) => s?.Match({|#0:() => "a"|}, () => "b", () => "c")?.ToUpper(); + + static string Trimmed(Holder h) => h.Status?.Match({|#1:() => " a"|}, () => "b", () => "c")?.Trim(); + + static int? Unbound(Subjects.Unwoven? u) => u?{|CS1061:.Match|}({|#2:() => 1|}, () => 2)?.GetHashCode(); + } + """, + Sets + """ + sealed class Holder + { + public Subjects.Status Status { get; set; } = Subjects.Status.Open; + } + + static class Use + { + static string Upper(Subjects.Status? s) => s?.Match(open: () => "a", shipped: () => "b", @new: () => "c")?.ToUpper(); + + static string Trimmed(Holder h) => h.Status?.Match(open: () => " a", shipped: () => "b", @new: () => "c")?.Trim(); + + static int? Unbound(Subjects.Unwoven? u) => u?{|CS1061:.Match|}(low: () => 1, high: () => 2)?.GetHashCode(); + } + """) + { + ExpectedDiagnostics = + { + Cmtk0012(0, "Status", "open:, shipped:, @new:"), + Cmtk0012(1, "Status", "open:, shipped:, @new:"), + Cmtk0012(2, "Unwoven", "low:, high:"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); } From 7c3084058a679b6d8a2190dfeaf85824d05f267b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:53:56 +0200 Subject: [PATCH 083/147] fix: CMTK0010 reports from generated code only where it maps to Razor markup TypeArgumentConstraintAnalyzer called ReportDiagnostic directly, the one rule that bypassed GeneratedCode.Report, so a breach in a file with an auto-generated header, a .g.cs or .Designer.cs name, or under #line hidden was an Error where every other rule is silent. Both of its paths, the written argument and the inferred one, now report through GeneratedCode. GeneratedCodeTests covered CMTK0001 to CMTK0009 only. It now holds CMTK0010 (both paths), CMTK0011 and CMTK0012 to the contract under a .razor and a .cshtml mapping, and reads the analyzers' sources for any direct ReportDiagnostic call, so a rule added later is held to it too. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 5 +- .../TypeArgumentConstraintAnalyzer.cs | 23 ++-- .../AnalyzerHelpLinkTests.cs | 2 +- .../GeneratedCodeTests.cs | 103 +++++++++++++++++- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index 5fea399..902ecce 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -59,7 +59,10 @@ since the verifier tests never weave. where a location maps (`#line`) to a `.razor` or `.cshtml` file: skipping generated code, as Roslyn does by default, silenced every rule inside Razor components, CMTK0001 included. Anything else generated stays unreported, which keeps EF's compiled model out of CMTK0004 (the smoke test's AOT -consumer is the measurement). `GeneratedCode.Report` is the one reporting path. +consumer is the measurement). `GeneratedCode.Report` is the one reporting path. CMTK0010 called +`ReportDiagnostic` itself until 2.0.0's pre-tag review, and so reported from every generated file; +`GeneratedCodeTests` holds each rule to the contract in a `.razor` and a `.cshtml` mapping, and +reads the analyzers' sources for a direct `ReportDiagnostic`, so a rule added later is held too. **Transformed code (decided 2026-09-28, [decision 33](adr/0033-analyzers-run-code-metalama-transformed.md)).** Where Metalama compiles a project, source generators run after its transformation, so the Razor compiler's output is not in the source an diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index efd5b50..5d0e41c 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -113,7 +112,10 @@ private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, TypeKi var receiver = symbol is IMethodSymbol { IsGenericMethod: true } ? Receiver(name, context.SemanticModel, context.CancellationToken) : null; for (var i = 0; i < arguments.Count; i++) - Report(context.ReportDiagnostic, symbols, arguments[i], receiver, name.TypeArgumentList.Arguments[i].GetLocation()); + { + foreach (var diagnostic in Diagnostics(symbols, arguments[i], receiver, name.TypeArgumentList.Arguments[i].GetLocation())) + context.Report(diagnostic); + } } /// Whether is inside the argument of a nameof, the operator and not a method of that name. @@ -188,14 +190,19 @@ private static void AnalyzeInferred(OperationAnalysisContext context, TypeKitSym var receiver = member is IMethodSymbol { IsGenericMethod: true } ? Receiver(name ?? reported, context.Operation.SemanticModel, context.CancellationToken) : null; foreach (var argument in arguments) - Report(context.ReportDiagnostic, symbols, argument, receiver, reported.GetLocation()); + { + foreach (var diagnostic in Diagnostics(symbols, argument, receiver, reported.GetLocation())) + context.Report(diagnostic); + } } - private static void Report(Action report, TypeKitSymbols symbols, TypeArgument argument, ITypeSymbol? receiver, Location location) - { - foreach (var breach in Breaches(symbols, argument.Parameter, argument.Argument, receiver)) - report(Diagnostic.Create(Rule, location, argument.Argument.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), argument.Parameter.Name, Owner(argument.Owner), breach)); - } + /// + /// One diagnostic per constraint breaks, for the caller to report + /// through , which keeps generated code that maps to no markup quiet. + /// + private static IEnumerable Diagnostics(TypeKitSymbols symbols, TypeArgument argument, ITypeSymbol? receiver, Location location) => + Breaches(symbols, argument.Parameter, argument.Argument, receiver) + .Select(breach => Diagnostic.Create(Rule, location, argument.Argument.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), argument.Parameter.Name, Owner(argument.Owner), breach)); /// A type parameter, the argument it was given, and the generic that declares it. private readonly struct TypeArgument(ITypeParameterSymbol parameter, ITypeSymbol argument, ISymbol owner) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs index 632bca9..f83d653 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerHelpLinkTests.cs @@ -58,7 +58,7 @@ public void The_README_states_every_rules_severity(string analyzer) } } - private static string RepositoryRoot() + internal static string RepositoryRoot() { for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) if (File.Exists(Path.Combine(directory.FullName, "CodoMetis.TypeKit.slnx"))) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs index 17833d0..4a3e473 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/GeneratedCodeTests.cs @@ -62,16 +62,20 @@ public partial class Component { } """; + private const string Razor = "Components/Component.razor"; + + private const string Cshtml = "Views/Home/Index.cshtml"; + /// /// The generated file. $ in a trigger becomes a distinct suffix per copy, and only the copy /// under the mapping to the markup keeps its {|#0:…|} location. /// - private static string Generated(string trigger) => + private static string Generated(string trigger, string markup) => "// \n" + Usings + $$""" public partial class Component { - #line 7 "Components/Component.razor" + #line 7 "{{markup}}" {{trigger.Replace("$", "Mapped")}} #line default #line hidden @@ -83,11 +87,11 @@ public partial class Component private static string Unmarked(string trigger) => trigger.Replace("{|#0:", "").Replace("|}", ""); - private static Task Verify(string trigger, string id, DiagnosticSeverity severity, string subjects = Subjects) + private static Task Verify(string trigger, string id, DiagnosticSeverity severity, string subjects = Subjects, string markup = Razor) where TAnalyzer : DiagnosticAnalyzer, new() { var test = new AnalyzerTest(subjects); - test.TestState.Sources.Add(("Component.razor.g.cs", Generated(trigger))); + test.TestState.Sources.Add(("Component.razor.g.cs", Generated(trigger, markup))); test.TestState.AdditionalReferences.AddRange(RealEntityFrameworkCore.References); test.ExpectedDiagnostics.Add(new DiagnosticResult(id, severity).WithLocation(0)); return test.RunAsync(TestContext.Current.CancellationToken); @@ -130,6 +134,97 @@ public Task CMTK0008_reports_in_markup_only() => public Task CMTK0009_reports_in_markup_only() => Verify("private Plain First$(List ids) => {|#0:ids.FirstOrDefault()|};", "CMTK0009", DiagnosticSeverity.Warning); + /// + /// The rules of 2.0.0, each in a .razor and a .cshtml mapping: the mapped copy is + /// reported and the hidden and unmapped ones are not, so a rule that reported past + /// GeneratedCode fails here as surely as one that skipped generated code. + /// + public static TheoryData Markup() => [Razor, Cshtml]; + + private const string Constraints = + Subjects + + """ + public sealed class Box<[Abstract] T> { } + + public sealed class Concrete { } + + public static class Runner + { + public static void Run<[Abstract] T>(T command) { } + } + + """; + + /// A type argument written in source is checked by the syntax path, at the argument. + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0010_written_reports_in_markup_only(string markup) => + Verify("private object Make$() => new Box<{|#0:Concrete|}>();", "CMTK0010", DiagnosticSeverity.Error, Constraints, markup); + + /// A type argument inferred at a call is checked by the operation path, at the call. + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0010_inferred_reports_in_markup_only(string markup) => + Verify("private void Run$(Concrete command) => {|#0:Runner.Run(command)|};", "CMTK0010", DiagnosticSeverity.Error, Constraints, markup); + + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0011_reports_in_markup_only(string markup) => + Verify( + "public sealed class {|#0:Job$|} : IJob { public Job$(int port) { } }", + "CMTK0011", + DiagnosticSeverity.Error, + Subjects + "[RequireParameterlessConstructor] public interface IJob { }\n", + markup); + + private const string Sets = + Subjects + + """ + public sealed class Status : CodoMetis.TypeKit.ClosedSets.IClosedSet + { + public static readonly Status Open = new(); + public static readonly Status Shipped = new(); + + private Status() { } + + public string Name => ""; + public string QualifiedName => ""; + public static IReadOnlyList All => [Open, Shipped]; + public static bool TryParse([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? name, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out Status member) { member = null; return false; } + + public TResult Match(Func open, Func shipped) => open(); + } + + """; + + [Theory] + [MemberData(nameof(Markup))] + public Task CMTK0012_reports_in_markup_only(string markup) => + Verify("private int Pick$(Status status) => status.Match({|#0:() => 1|}, () => 2);", "CMTK0012", DiagnosticSeverity.Error, Sets, markup); + + /// + /// The tests above hold each rule of today to the one reporting path; this holds a rule added + /// later. A rule that calls ReportDiagnostic itself reports from every generated file, EF + /// Core's compiled model and #line hidden included, as CMTK0010 did until 2.0.0's review. + /// + [Fact] + public void No_rule_reports_past_GeneratedCode() + { + var files = Directory.GetFiles(Path.Combine(AnalyzerHelpLinkTests.RepositoryRoot(), "src", "CodoMetis.TypeKit.Analyzers"), "*.cs") + .Where(file => Path.GetFileName(file) != "GeneratedCode.cs") + .ToList(); + + files.Count.ShouldBeGreaterThanOrEqualTo(14, "The analyzer sources were not found, so this test checks nothing."); + + var calls = from file in files + let root = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(File.ReadAllText(file)).GetRoot(TestContext.Current.CancellationToken) + from name in root.DescendantNodes().OfType() + where name.Identifier.ValueText == "ReportDiagnostic" + select $"{Path.GetFileName(file)}:{name.GetLocation().GetLineSpan().StartLinePosition.Line + 1}"; + + calls.ShouldBeEmpty("These rules call ReportDiagnostic directly, so they report from generated code that maps to no markup. Report through GeneratedCode.Report."); + } + /// A Razor view compiles to generated code too, mapped to its .cshtml. [Fact] public Task A_cshtml_mapping_counts_as_markup() => From 44a31b1ff060b8281f1f8e0443f6d52207a65ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:58:56 +0200 Subject: [PATCH 084/147] fix: CMTK0011 never reports a struct, and says "an internal" constructor A struct always has a public parameterless constructor, implicit or declared, and can have no other (CS8958). Under any requirement but Public, CMTK0011 reported every struct and record struct as an Error only a suppression could answer, and the code fix wrote internal S() { }, which does not compile. A struct satisfies Public as it is, so the rule now reports no struct, and the fix offers nothing on one should it be handed a diagnostic there. The message read "declares a internal parameterless constructor", and the fix's title "Add a internal parameterless constructor": both now give the accessibility its article. Every accessibility's wording is pinned. ProtectedInternal and PrivateProtected, read from the attribute's enum by position, had no test of the analyzer or the fix: both are now held, in either keyword order, for a missing, an implicit and a wrong constructor. The roadmap's 2.1 can-wait item on the fix offered on a struct is dropped. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- design/roadmap.md | 5 +- docs/type-constraints.md | 1 + ...ParameterlessConstructorCodeFixProvider.cs | 10 +- src/CodoMetis.TypeKit.Analyzers/README.md | 6 +- ...equiredParameterlessConstructorAnalyzer.cs | 16 +- ...equireParameterlessConstructorAttribute.cs | 4 +- ...edParameterlessConstructorAnalyzerTests.cs | 245 ++++++++++++++++++ 8 files changed, 277 insertions(+), 12 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index 902ecce..b128ca9 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -40,7 +40,7 @@ exist. | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | | CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | -| CMTK0011 | A concrete type implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a type declaring no constructor satisfies `Public`. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | +| CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | diff --git a/design/roadmap.md b/design/roadmap.md index ee76641..bfdca18 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -23,9 +23,8 @@ item names the decision that scoped it. a nullable element; `Patch` under `Traverse` and `Map`. - CMTK1021 groups names ignoring case, which misses the one `Match` collision `ToLowerInvariant` makes and ordinal case folding does not (the Kelvin sign); it fails loudly, as LAMA0611 and CS0100. -- What the second outside review of 2026-10-13 rated can-wait. Analyzers: the CMTK0011 code fix - offered on a struct, which cannot have a non-public parameterless constructor (CS8958), and the - trivia it drops; CMTK0010 on an enum or a delegate under `[Concrete]` and on a type parameter whose +- What the second outside review of 2026-10-13 rated can-wait. Analyzers: the trivia the CMTK0011 + code fix drops; CMTK0010 on an enum or a delegate under `[Concrete]` and on a type parameter whose constraint already makes it a record, and its cost (`GetSymbolInfo` on every generic name, about 0.5 s on a 6.7k-line file); CMTK0003 on an async expression-bodied member in the IDE, where Metalama has not run. Generators: a C# keyword in a namespace or a member name fails with LAMA0041, diff --git a/docs/type-constraints.md b/docs/type-constraints.md index 28008b0..9219657 100644 --- a/docs/type-constraints.md +++ b/docs/type-constraints.md @@ -120,6 +120,7 @@ public sealed class Importer : Plugin; // CMTK0011: the impl | has a parameterless constructor of the wrong accessibility | at the constructor | changes it | | has a primary constructor | at the type | none: every other constructor would chain to the primary one, with arguments only its author knows | | is abstract, or an interface | no: only concrete descendants are checked | | +| is a struct or a record struct | no: its parameterless constructor is public and can be nothing else (CS8958), so it satisfies `Public` as it is and no other accessibility can be asked of it | | A record's copy constructor does not count. The attribute is read from metadata, so the marked interface can live in another package, and a host's contract reaches every implementation in every diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs index 54ca1bc..db46958 100644 --- a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs @@ -18,7 +18,8 @@ namespace CodoMetis.TypeKit.Analyzers; /// /// /// A type with a primary constructor gets no fix: every other constructor of such a type chains to -/// the primary one, and the arguments to chain with are the author's to choose. +/// the primary one, and the arguments to chain with are the author's to choose. Nor does a struct, +/// which the rule never reports: its parameterless constructor can only be public. /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RequiredParameterlessConstructorCodeFixProvider))] [Shared] @@ -51,6 +52,11 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var node = root.FindNode(diagnostic.Location.SourceSpan); var spelling = Spell(required); + // A struct's parameterless constructor is public and can be nothing else (CS8958): the rule + // reports none, and a fix that wrote another accessibility there would not compile. + if (node.FirstAncestorOrSelf() is { } declaring && declaring.Kind() is SyntaxKind.StructDeclaration or SyntaxKind.RecordStructDeclaration) + continue; + if (node.FirstAncestorOrSelf() is { } constructor) { context.RegisterCodeFix( @@ -64,7 +70,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) { context.RegisterCodeFix( CodeAction.Create( - $"Add a {spelling} parameterless constructor", + $"Add {(required == Accessibility.Internal ? "an" : "a")} {spelling} parameterless constructor", cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(type, WithConstructor(type, required)))), equivalenceKey: $"{DiagnosticId}:add"), diagnostic); diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 6b04dc7..a6bf62f 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -22,7 +22,7 @@ appears to: an instance that passed no factory, or a value object that was never | CMTK0008 | Warning | The wrapped values of two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves through `Equals(object)`. | | CMTK0009 | Warning | A call that returns the `default` of such a type when it finds nothing: `FirstOrDefault()`, `GetValueOrDefault()`, `Option.OrDefault()` and their kind. | | CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, an attribute, a call or a method group, written or inferred, and a member of a generic extension block, whose arguments the receiver gives. A type parameter forwarded without the attribute is a breach too, and a method implementing an interface's method, or overriding one, carries the constraint of what it implements, a static one and one a base class declares included. | -| CMTK0011 | Error | A concrete type that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. The code fix writes or changes the constructor. | +| CMTK0011 | Error | A concrete class that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. A struct is never reported: its parameterless constructor is public and can be nothing else. The code fix writes or changes the constructor. | | CMTK0012 | Error | A closed set's `Match` given an argument by position, which binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | The ids are public contract and never change meaning. The generators' own build errors, @@ -417,6 +417,10 @@ no fix: every other constructor of such a type chains to the primary one, with a author knows. The requirement reaches a type through any chain of base classes and interfaces, and the attribute is read from metadata, so the marked type can live in another package. +A struct or record struct is never reported. It always has a public parameterless constructor, the +implicit one or one it declares, and C# allows it no other (CS8958): it satisfies `Public` as it is, +and no other accessibility can be asked of it. + ## CMTK0012 A closed set's `Match` takes one delegate per member, in the order the members are declared, so diff --git a/src/CodoMetis.TypeKit.Analyzers/RequiredParameterlessConstructorAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/RequiredParameterlessConstructorAnalyzer.cs index 6ec3dcd..1ebc8cd 100644 --- a/src/CodoMetis.TypeKit.Analyzers/RequiredParameterlessConstructorAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/RequiredParameterlessConstructorAnalyzer.cs @@ -16,6 +16,7 @@ namespace CodoMetis.TypeKit.Analyzers; /// when it lacks the constructor. The rule puts that error on the type, in whichever project /// declares it. A class that declares no constructor has a public one and is silent for /// Public; a wrong accessibility is reported at the constructor, so the fix changes it there. +/// A struct is never reported: its parameterless constructor is public and can be nothing else. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class RequiredParameterlessConstructorAnalyzer : DiagnosticAnalyzer @@ -55,9 +56,12 @@ private static void AnalyzeType(SymbolAnalysisContext context, TypeKitSymbols sy { var type = (INamedTypeSymbol)context.Symbol; - // Only a type the host could create: an interface, an abstract or static type is never - // constructed, and carries the requirement for its descendants. - if (type.TypeKind is not (TypeKind.Class or TypeKind.Struct) || type.IsAbstract || type.IsStatic || type.IsAnonymousType) return; + // Only a class the host could create: an interface, an abstract or static type is never + // constructed, and carries the requirement for its descendants. A struct always has a public + // parameterless constructor, the implicit one or one it declares, and can have no other + // (CS8958): it satisfies Public as it is, and an Error for any other accessibility could only + // be suppressed, never answered in the code. + if (type.TypeKind is not TypeKind.Class || type.IsAbstract || type.IsStatic || type.IsAnonymousType) return; var parameterless = type.InstanceConstructors.FirstOrDefault(constructor => constructor.Parameters.IsEmpty); var reported = new HashSet(); @@ -68,7 +72,7 @@ private static void AnalyzeType(SymbolAnalysisContext context, TypeKitSymbols sy var state = parameterless is null ? "declares no parameterless constructor" : parameterless.IsImplicitlyDeclared ? "has the implicit public parameterless constructor" - : $"declares a {Spell(parameterless.DeclaredAccessibility)} parameterless constructor"; + : $"declares {WithArticle(parameterless.DeclaredAccessibility)} parameterless constructor"; // At the constructor where one is declared, so the fix changes it there; at the type // otherwise, where the fix adds one. @@ -97,4 +101,8 @@ private static void AnalyzeType(SymbolAnalysisContext context, TypeKitSymbols sy Accessibility.ProtectedAndInternal => "private protected", _ => "private", }; + + /// The accessibility as C# writes it, after the article English gives it: "an internal", "a protected internal". + private static string WithArticle(Accessibility accessibility) => + (accessibility == Accessibility.Internal ? "an " : "a ") + Spell(accessibility); } diff --git a/src/CodoMetis.TypeKit/RequireParameterlessConstructorAttribute.cs b/src/CodoMetis.TypeKit/RequireParameterlessConstructorAttribute.cs index b0bdf0a..b563bf3 100644 --- a/src/CodoMetis.TypeKit/RequireParameterlessConstructorAttribute.cs +++ b/src/CodoMetis.TypeKit/RequireParameterlessConstructorAttribute.cs @@ -16,7 +16,9 @@ namespace CodoMetis.TypeKit; /// A class that declares no constructor has a public parameterless one and satisfies /// as it is. A record or class with a primary /// constructor has none, and its fix is written by hand, since every other constructor of such a -/// type chains to the primary one. +/// type chains to the primary one. A struct is never reported: its parameterless constructor is +/// public and can be nothing else, so it satisfies and +/// no other accessibility can be asked of it. /// /// /// The accessibility the constructor must have. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs index c46a970..0d80ddb 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Testing; @@ -29,6 +30,12 @@ public abstract class Module { } [RequireParameterlessConstructor(ConstructorAccessibility.Private)] public interface IActivated { } + + [RequireParameterlessConstructor(ConstructorAccessibility.ProtectedInternal)] + public abstract class Widget { } + + [RequireParameterlessConstructor(ConstructorAccessibility.PrivateProtected)] + public abstract class Gadget { } } """; @@ -91,6 +98,106 @@ public sealed class Orders : Subjects.IEndpoint Cmtk0011("Orders", "declares a private parameterless constructor", "IEndpoint", "public")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// The state names the declared accessibility with its article, as English writes it: "an + /// internal", "a protected internal". Every accessibility C# lets a class's constructor have. + /// + [Theory] + [InlineData("internal", "declares an internal parameterless constructor")] + [InlineData("protected", "declares a protected parameterless constructor")] + [InlineData("protected internal", "declares a protected internal parameterless constructor")] + [InlineData("private protected", "declares a private protected parameterless constructor")] + [InlineData("private", "declares a private parameterless constructor")] + public Task The_message_names_the_declared_accessibility_with_its_article(string declared, string state) => + Test( + $$""" + public class Orders : Subjects.IEndpoint + { + {{declared}} {|#0:Orders|}() { } + } + """, + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage($"'Orders' {state}, but IEndpoint requires one that is public")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task A_public_constructor_against_a_non_public_requirement_says_a_public() => + Test( + """ + public class Identity : Subjects.Module + { + public {|#0:Identity|}() { } + } + """, + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("'Identity' declares a public parameterless constructor, but Module requires one that is internal")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The two accessibilities of two keywords, read from the attribute's enum by position (3 and 4): + /// each is satisfied by its own constructor, in either keyword order, and not by the other's. + /// + [Fact] + public Task Protected_internal_and_private_protected_are_each_required_as_written() => + Test( + """ + public class WidgetOne : Subjects.Widget { protected internal WidgetOne() { } } + + public class WidgetTwo : Subjects.Widget { internal protected WidgetTwo() { } } + + public class GadgetOne : Subjects.Gadget { private protected GadgetOne() { } } + + public class GadgetTwo : Subjects.Gadget { protected private GadgetTwo() { } } + + public class WidgetWrong : Subjects.Widget { private protected {|#0:WidgetWrong|}() { } } + + public class GadgetWrong : Subjects.Gadget { protected internal {|#1:GadgetWrong|}() { } } + + public class {|#2:WidgetImplicit|} : Subjects.Widget { } + + public class {|#3:GadgetImplicit|} : Subjects.Gadget { } + """, + Cmtk0011("WidgetWrong", "declares a private protected parameterless constructor", "Widget", "protected internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("GadgetWrong", "declares a protected internal parameterless constructor", "Gadget", "private protected"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(2).WithArguments("WidgetImplicit", "has the implicit public parameterless constructor", "Widget", "protected internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(3).WithArguments("GadgetImplicit", "has the implicit public parameterless constructor", "Gadget", "private protected")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A struct always has a public parameterless constructor, the implicit one or one it declares, and + /// can have no other (CS8958). It satisfies Public as it is, and no other accessibility can + /// be asked of it, so no requirement is reported on it: an Error its author could answer only by a + /// suppression would be no rule. + /// + [Theory] + [InlineData("Public")] + [InlineData("Internal")] + [InlineData("Protected")] + [InlineData("ProtectedInternal")] + [InlineData("PrivateProtected")] + [InlineData("Private")] + public Task A_struct_or_record_struct_is_never_reported(string accessibility) => + Test( + $$""" + [RequireParameterlessConstructor(ConstructorAccessibility.{{accessibility}})] + public interface IJob { } + + public struct Implicit : IJob { } + + public struct Declared : IJob { public Declared() { } } + + public struct Parameterised : IJob { public Parameterised(int port) { } } + + public record struct Record : IJob; + + public readonly record struct DeclaredRecord : IJob { public DeclaredRecord() { } } + + public record struct Positional(int Port) : IJob; + """) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task The_implicit_public_constructor_does_not_satisfy_a_non_public_requirement() => Test( @@ -230,6 +337,144 @@ internal Identity() } .RunAsync(TestContext.Current.CancellationToken); + /// The two accessibilities of two keywords, added as C# writes them. + [Theory] + [InlineData("Widget", "protected internal")] + [InlineData("Gadget", "private protected")] + public Task The_fix_adds_a_constructor_of_two_keywords(string marked, string modifiers) => + new CodeFixTest( + Subjects + $$""" + public class {|#0:Orders|} : Subjects.{{marked}} + { + public Orders(int port) { } + } + """, + Subjects + $$""" + public class Orders : Subjects.{{marked}} + { + {{modifiers}} Orders() + { + } + + public Orders(int port) { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", marked, modifiers) }, + CodeActionVerifier = (action, verifier) => verifier.Equal($"Add a {modifiers} parameterless constructor", action.Title), + } + .RunAsync(TestContext.Current.CancellationToken); + + /// Each keyword of the wrong accessibility goes, whichever accessibility of one or two keywords replaces it. + [Theory] + [InlineData("Widget", "public", "a public", "protected internal")] + [InlineData("Widget", "private protected", "a private protected", "protected internal")] + [InlineData("Gadget", "protected internal", "a protected internal", "private protected")] + [InlineData("Gadget", "internal", "an internal", "private protected")] + public Task The_fix_changes_a_constructor_to_one_of_two_keywords(string marked, string declared, string named, string required) => + new CodeFixTest( + Subjects + $$""" + public class Orders : Subjects.{{marked}} + { + {{declared}} {|#0:Orders|}() { } + } + """, + Subjects + $$""" + public class Orders : Subjects.{{marked}} + { + {{required}} Orders() { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", $"declares {named} parameterless constructor", marked, required) }, + CodeActionVerifier = (action, verifier) => verifier.Equal($"Make the constructor {required}", action.Title), + } + .RunAsync(TestContext.Current.CancellationToken); + + /// The action's title agrees its article with the accessibility, as the message does. + [Fact] + public Task The_fix_s_title_says_an_internal() => + new CodeFixTest( + Subjects + "public sealed class {|#0:Identity|} : Subjects.Module { }", + Subjects + """ + public sealed class Identity : Subjects.Module + { + internal Identity() + { + } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal") }, + CodeActionVerifier = (action, verifier) => verifier.Equal("Add an internal parameterless constructor", action.Title), + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The rule reports no struct, so the fix never meets one. Handed a diagnostic there all the same, + /// as the rule gave one before 2.0.0 was tagged, it offers nothing: internal S() { } in a + /// struct is CS8958. The diagnostics are made here, at each place the rule reported, with the + /// rule's own descriptor; a class beside them gets its fix, so the property they carry is one the + /// fix reads. + /// + [Fact] + public async Task The_fix_offers_nothing_on_a_struct() + { + const string source = + """ + public interface IJob { } + + public struct Implicit : IJob { } + + public struct Declared : IJob { public Declared() { } } + + public record struct Record : IJob; + + public readonly record struct DeclaredRecord : IJob { public DeclaredRecord() { } } + + public sealed class Control : IJob { public Control(int port) { } } + """; + + var token = TestContext.Current.CancellationToken; + var rule = new RequiredParameterlessConstructorAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + using var workspace = new AdhocWorkspace(); + var document = workspace.AddProject("Structs", LanguageNames.CSharp).AddDocument("Structs.cs", source); + var root = await document.GetSyntaxRootAsync(token) ?? throw new InvalidOperationException("No syntax root."); + + // Where the rule reported: at a declared parameterless constructor, or at the type. + var places = root.DescendantNodes().OfType() + .Where(type => type.BaseList is not null) + .Select(type => (type.Identifier.ValueText, + Location: type.Members.OfType() + .FirstOrDefault(constructor => constructor.ParameterList.Parameters.Count == 0)?.Identifier.GetLocation() + ?? type.Identifier.GetLocation())) + .ToList(); + + places.Count.ShouldBe(5, "The source's types were not found, so this test checks nothing."); + + foreach (var (name, location) in places) + { + var diagnostic = Diagnostic.Create( + rule, + location, + ImmutableDictionary.Empty.Add(RequiredParameterlessConstructorAnalyzer.RequiredProperty, nameof(Accessibility.Internal)), + name, + "declares no parameterless constructor", + "IJob", + "internal"); + + var actions = new List(); + await new RequiredParameterlessConstructorCodeFixProvider().RegisterCodeFixesAsync( + new Microsoft.CodeAnalysis.CodeFixes.CodeFixContext(document, diagnostic, (action, _) => actions.Add(action.Title), token)); + + if (name == "Control") + actions.ShouldBe(["Add an internal parameterless constructor"], "The class beside the structs got no fix, so the diagnostics here carry nothing the fix reads."); + else + actions.ShouldBeEmpty($"The fix offers '{string.Join("', '", actions)}' on the struct {name}, which can have no parameterless constructor but a public one (CS8958)."); + } + } + /// Every other constructor of a type with a primary constructor chains to it, with arguments only the author knows. [Fact] public Task A_type_with_a_primary_constructor_gets_no_fix() => From 1f8685a92ea247bf80be1e08b6c5485d2561b641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:01:43 +0200 Subject: [PATCH 085/147] test: the analyzer package is held to shipping its code-fix assembly AnalyzerPackagingTests asserted only the analyzer assembly under analyzers/dotnet/cs. Removing the item that packs CodoMetis.TypeKit.Analyzers.CodeFixes.dll beside it kept every packaging and convention test green, while the package lost CMTK0011's and CMTK0012's fixes, which the 2.0.0 changelog promises inside it. The pack is now read for it. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 3 ++- .../AnalyzerPackagingTests.cs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/design/analyzers.md b/design/analyzers.md index b128ca9..2b921a9 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -16,7 +16,8 @@ code fixes under `analyzers/dotnet/cs`). `CodoMetis.TypeKit` takes a package dep - What does drop it: a project reference with `ReferenceOutputAssembly="false"` emits **no dependency at all**. The reference is therefore an ordinary one with `Private="false"`. - **Guards:** `AnalyzerPackagingTests` packs both packages and reads the nuspec and the package - layout. The consumer smoke test (phase 6) asserts CMTK0001 fires, directly and transitively. + layout, the code-fix assembly beside the analyzer's included. The consumer smoke test (phase 6) + asserts CMTK0001 fires, directly and transitively. Attributes stay in `CodoMetis.TypeKit`. The analyzer resolves them and the interfaces with `GetTypeByMetadataName` and compares symbols ([Discovery](discovery.md)), so it does not reference the base package. diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs index 0a9c147..990b830 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/AnalyzerPackagingTests.cs @@ -60,6 +60,22 @@ public void The_analyzer_package_ships_the_analyzer_and_nothing_to_compile_again "The analyzer package has a lib/ folder, so every consumer would compile against the analyzer assembly."); } + /// + /// The code fixes are a project of their own (RS1038: an analyzer assembly must not reference + /// Workspaces), packed beside the analyzer by an item in the analyzer's project file. Without that + /// item the pack succeeds, the analyzer still ships and loads, and CMTK0011 and CMTK0012 lose their + /// fixes without a word; found by removing it while every other packaging test stayed green. + /// + [Fact] + public void The_analyzer_package_ships_the_code_fixes_beside_the_analyzer() + { + var entries = packs.Entries(AnalyzerPackage); + + entries.ShouldContain($"analyzers/dotnet/cs/{AnalyzerPackage}.CodeFixes.dll", + $"{AnalyzerPackage} does not pack {AnalyzerPackage}.CodeFixes.dll under analyzers/dotnet/cs, so no IDE offers a CMTK code fix. " + + "Pack it with a None item beside the analyzer's own."); + } + [Fact] public void The_analyzer_package_is_not_a_development_dependency() { From a7003bf09abf169bc99206e320fc7e877f858056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:03:56 +0200 Subject: [PATCH 086/147] fix: a tap through WaitAsync on a held task no longer reports CMTK0003 await held.WaitAsync(t); is silent, since WaitAsync and ConfigureAwait hand on the task they are called on, but await held.WaitAsync(t).TapAsync(log); still reported: the tap's receiver was compared to a local, parameter or field without looking through the pass-through. It now is, as the statement's own call already was. A call's task through WaitAsync still reports. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- .../IgnoredOutcomeAnalyzer.cs | 12 +++++------- .../IgnoredOutcomeAnalyzerTests.cs | 5 ++++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index 2b921a9..c2fbc4a 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -33,7 +33,7 @@ exist. |---|---|---|---| | CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | | CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | -| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver, and so is a chain of them on one (`r.Tap(log).TapError(warn)`, `(await pending).Tap(log)`), measured as an Error's false report before 2.0.0 was tagged. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | +| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver, and so is a chain of them on one (`r.Tap(log).TapError(warn)`, `(await pending).Tap(log)`, `pending.WaitAsync(t).TapAsync(log)`), measured as an Error's false report before 2.0.0 was tagged. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | | CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | | CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | | CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index 201b3ff..c91afc8 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -385,19 +385,17 @@ private static bool ReturnsItsStoredReceiver(IInvocationOperation invocation, Ty /// Whether the caller still holds : a local, a parameter or a field; a /// tap that returned such a receiver, so that r.Tap(log).TapError(warn) holds at every link; /// or what a task so held completes with, awaited in the chain, (await pending).Tap(log). + /// ConfigureAwait and WaitAsync hand on the task they are called on, so + /// pending.WaitAsync(t).TapAsync(log) is a tap on pending. /// - private static bool IsStored(IOperation? receiver, TypeKitSymbols symbols, Tasks tasks) - { - while (receiver is IConversionOperation { IsImplicit: true } conversion) receiver = conversion.Operand; - - return receiver switch + private static bool IsStored(IOperation? receiver, TypeKitSymbols symbols, Tasks tasks) => + receiver is not null && tasks.PassedOn(receiver) switch { ILocalReferenceOperation or IParameterReferenceOperation or IFieldReferenceOperation => true, IInvocationOperation tap => ReturnsItsStoredReceiver(tap, symbols, tasks), - IAwaitOperation { Operation: var awaited } => IsStored(tasks.PassedOn(awaited), symbols, tasks), + IAwaitOperation { Operation: var awaited } => IsStored(awaited, symbols, tasks), _ => false, }; - } /// The task and collection types, resolved once per compilation. private sealed class Tasks( diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index aafc3f6..937fc9e 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -318,8 +318,10 @@ public static async Task Run(Service service, Result parameter, Resu (await pending).Tap(_ => { }).TapError(_ => { }); (await pending.TapAsync(_ => { })).TapError(_ => { }); (await pending.TapAsync(_ => { }).ConfigureAwait(false)).TapError(_ => { }); + await pending.WaitAsync(TimeSpan.FromSeconds(1)).TapAsync(_ => { }).TapErrorAsync(_ => { }); {|#0:service.Load().Tap(_ => { }).TapError(_ => { })|}; + {|#6:await service.LoadAsync().WaitAsync(TimeSpan.FromSeconds(1)).TapAsync(_ => { })|}; {|#1:await service.LoadAsync().TapAsync(_ => { }).TapErrorAsync(_ => { })|}; {|#2:(await service.LoadAsync().TapAsync(_ => { })).TapError(_ => { })|}; {|#3:(await service.LoadAsync()).Tap(_ => { })|}; @@ -333,7 +335,8 @@ public static async Task Run(Service service, Result parameter, Resu Cmtk0003(2, "Result", "TapError"), Cmtk0003(3, "Result", "Tap"), Cmtk0003(4, "Result", "Tap"), - Cmtk0003(5, "Result", "TapAsync")) + Cmtk0003(5, "Result", "TapAsync"), + Cmtk0003(6, "Result", "TapAsync")) .RunAsync(TestContext.Current.CancellationToken); /// From 84aaec20ebc316ad3e901ac6de6b34c41585773b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:56:36 +0200 Subject: [PATCH 087/147] fix: Result.Catch lets a cancellation that Wait() or .Result wraps through, and a wrapped timeout stays the error Task.Wait() and .Result throw a cancelled task's cancellation inside an AggregateException, which every Catch overload answered as the error, so Recover ran on a cancelled call. An aggregate that holds cancellations alone now passes through unchanged, as it was thrown; one that holds anything beside them, a timeout included, is the error. .Result on a cancelled task throws a new TaskCanceledException that names the task and holds no inner exception (measured on .NET 10), so for an HttpClient call whose Timeout elapsed the TimeoutException is gone from what is thrown. The cancelled task still holds it, and is read, so that call is the error as decision 46 has it and as CatchAsync answers it. Co-Authored-By: Claude Opus 5.5 --- docs/result.md | 7 +- src/CodoMetis.TypeKit/README.md | 10 +- src/CodoMetis.TypeKit/ResultExtensions.cs | 63 ++++++++-- .../ResultCatchTests.cs | 109 ++++++++++++++++++ 4 files changed, 171 insertions(+), 18 deletions(-) diff --git a/docs/result.md b/docs/result.md index 4208c80..c0c237b 100644 --- a/docs/result.md +++ b/docs/result.md @@ -196,8 +196,11 @@ An exception is for the code that called, not for a client: `ToHttpResult()` doe the task, so an exception thrown after its first `await` is the error too; a delegate that returns a `Task` given to `Catch` is a compile error naming `CatchAsync`, since the task's exception would be thrown after `Catch` had returned. An `OperationCanceledException` passes through every overload, -since a cancellation is not an outcome, except one over a `TimeoutException`: that is how `HttpClient` -reports its `Timeout` elapsing, the call failing, and it is the error. +since a cancellation is not an outcome, and so does an `AggregateException` that holds cancellations +alone, as `Wait()` and `.Result` throw for a cancelled task; one that holds anything beside them is the +error, as it was thrown. A cancellation over a `TimeoutException` is the error, wrapped or not: that is +how `HttpClient` reports its `Timeout` elapsing, the call failing, and `.Result` on such a call is the +error too. `Map`, `Bind` and the rest are unchanged: a selector that throws propagates. ## Query syntax diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 0d91787..f2f5c28 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -193,10 +193,12 @@ and `Catch(() => { … })` with one is the command form, for a value: given one that returns a value, which it would discard, it is a compile error naming the valued form. `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error too. An `OperationCanceledException` passes through all of them, since a cancellation is not an -outcome and `Recover` would run on it as on a failure. The exception is a timeout: `HttpClient` -reports its `Timeout` elapsing as an `OperationCanceledException` over a `TimeoutException`, which -is the call failing, so it is the error. A narrowed `Catch` names `TaskCanceledException` or -`OperationCanceledException` to catch it alone. A delegate that returns a `Task`, an `async` lambda +outcome and `Recover` would run on it as on a failure, and so does an `AggregateException` that holds +cancellations alone, as `Wait()` and `.Result` throw for a cancelled task; one that holds anything +beside them is the error, as it was thrown. The exception is a timeout: `HttpClient` reports its +`Timeout` elapsing as an `OperationCanceledException` over a `TimeoutException`, which is the call +failing, so it is the error, wrapped or not, `.Result` on such a call included. A narrowed `Catch` +names `TaskCanceledException` or `OperationCanceledException` to catch it alone. A delegate that returns a `Task`, an `async` lambda among them, given to `Catch` or its command form is a compile error naming `CatchAsync`, since the task's exception would be thrown after `Catch` had returned. A `ValueTask`, or a task spelled as the type argument (`Catch, …>`), is refused when called, before the delegate runs; `CatchAsync` diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 810bf1d..2431d6f 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -38,10 +38,14 @@ public static Error Error(T error) where T : notnull => /// /// /// An passes through: a cancellation is not an outcome, - /// and caught it would read as a failure and Recover would run on it. One whose - /// is a is the error: that is - /// how reports its Timeout elapsing, a failure of - /// the call and not the caller's cancellation. A delegate that returns a is + /// and caught it would read as a failure and Recover would run on it. So does an + /// that holds cancellations and nothing else, as Task.Wait() + /// and .Result throw for a cancelled task; one that holds anything beside them is the error. + /// A cancellation whose is a is + /// the error, wrapped or not: that is how reports its + /// Timeout elapsing, a failure of the call and not the caller's cancellation, and .Result + /// on that call is the error too, although what it throws no longer holds the + /// : the cancelled task still does. A delegate that returns a is /// refused at compile time, since the task's exception is thrown after this call returns; /// waits for it. /// @@ -168,12 +172,46 @@ public static Result Catch(Action action) /// /// Whether is a cancellation, which every Catch lets through: an - /// that is no timeout. - /// throws a over a when its - /// Timeout elapses, which is the call failing, so that one is caught. + /// that is no timeout, or an + /// that holds such cancellations and nothing else, as Task.Wait() and .Result throw for + /// a cancelled task. An aggregate that holds anything else beside them, a timeout included, is the error. /// - private static bool IsCancellation(Exception exception) => - exception is OperationCanceledException { InnerException: not TimeoutException }; + private static bool IsCancellation(Exception exception) + { + if (exception is not AggregateException aggregate) return IsUnwrappedCancellation(exception); + + var inner = aggregate.Flatten().InnerExceptions; + + return inner.Count > 0 && inner.All(IsUnwrappedCancellation); + } + + private static bool IsUnwrappedCancellation(Exception exception) => + exception is OperationCanceledException cancellation && !IsTimeout(cancellation); + + /// + /// Whether is a timeout, the call failing rather than the caller + /// cancelling: throws a over a + /// when its Timeout elapses. Task.Wait() and .Result + /// on that cancelled task throw a new one instead, naming the task and holding no inner exception + /// (measured on .NET 10), so the task's own cancellation is read: the task has completed, and + /// GetResult rethrows it without waiting. + /// + private static bool IsTimeout(OperationCanceledException cancellation) + { + if (cancellation.InnerException is TimeoutException) return true; + if (cancellation is not TaskCanceledException { InnerException: null, Task: { IsCanceled: true } task }) return false; + + try + { + task.GetAwaiter().GetResult(); + } + catch (OperationCanceledException own) + { + return own.InnerException is TimeoutException; + } + + return false; + } /// Refused: the task's exception is thrown after Catch has returned. Use . /// @@ -250,11 +288,12 @@ private static void RefuseTask(string method) /// /// /// An passes through, so a cancelled token surfaces as - /// cancellation; a timeout, which throws as a cancellation - /// over a , is the error. A delegate returning a ValueTask + /// cancellation, and so does an of cancellations alone; a timeout, + /// which throws as a cancellation over a + /// , is the error. A delegate returning a ValueTask /// needs .AsTask() or an async lambda: a ValueTask overload beside this one is /// ambiguous for every async lambda. - /// See for null. + /// See for cancellations, timeouts and null. /// /// The asynchronous call whose outcome is wanted. /// The type of the value. diff --git a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs index ce2c220..4ffcd58 100644 --- a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs @@ -237,6 +237,115 @@ public async Task A_cancellation_that_is_no_timeout_still_passes_through() await Should.ThrowAsync(() => Result.CatchAsync(async Task () => { await Task.Yield(); throw cancelled; })); } + private static Task Cancelled => Task.FromCanceled(new CancellationToken(canceled: true)); + + private static Task CancelledCommand => Task.FromCanceled(new CancellationToken(canceled: true)); + + private static void WaitsForACancelledTask() => CancelledCommand.Wait(); + + private static int ReadsACancelledTask() => Cancelled.Result; + + /// + /// Task.Wait() and .Result throw a cancelled task's cancellation inside an + /// . One that holds nothing but cancellations is a cancellation, and + /// passes through every overload as it was thrown: it was the error, so Recover ran on a + /// cancelled call. + /// + [Fact] + public async Task A_cancellation_that_Wait_or_Result_wraps_passes_through_every_overload() + { + Should.Throw(() => Result.Catch(() => Cancelled.Result)).InnerException.ShouldBeOfType(); + Should.Throw(() => Result.Catch(() => CancelledCommand.Wait())).InnerException.ShouldBeOfType(); + Should.Throw(() => Result.Catch(() => Cancelled.Result)); + Should.Throw(() => Result.Catch(() => Cancelled.Result)); + Should.Throw(() => Result.Catch(() => CancelledCommand.Wait())); + Should.Throw(() => Result.Catch(() => CancelledCommand.Wait())); + + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); return Cancelled.Result; })); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); CancelledCommand.Wait(); })); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); return Cancelled.Result; })); + await Should.ThrowAsync(() => Result.CatchAsync(async () => { await Task.Yield(); CancelledCommand.Wait(); })); + } + + /// Nothing catches and rethrows it: the instance thrown arrives, with the stack of its throw. + [Fact] + public void A_wrapped_cancellation_arrives_as_it_was_thrown() + { + var wrapped = new AggregateException(new OperationCanceledException(), new AggregateException(new TaskCanceledException())); + + Should.Throw(() => Result.Catch(int () => throw wrapped)).ShouldBeSameAs(wrapped, "nested aggregates of cancellations are a cancellation too"); + Should.Throw(() => Result.Catch(void () => throw wrapped)).ShouldBeSameAs(wrapped); + + Should.Throw(() => Result.Catch(ReadsACancelledTask)).StackTrace!.ShouldContain(nameof(ReadsACancelledTask), Case.Sensitive, "the stack is the original throw's"); + Should.Throw(() => Result.Catch(WaitsForACancelledTask)).StackTrace!.ShouldContain(nameof(WaitsForACancelledTask), Case.Sensitive, "the stack is the original throw's"); + } + + /// + /// A cancellation beside a failure, as Task.WaitAll throws for one task cancelled and one + /// faulted, is not a cancellation alone: the aggregate is the error, as it was thrown. + /// + [Fact] + public async Task An_aggregate_that_mixes_a_cancellation_with_a_failure_is_the_error() + { + Result.Catch(() => Task.WaitAll(CancelledCommand, Task.FromException(Boom))).TryGetError(out var waited).ShouldBeTrue(); + waited.ShouldBeOfType().InnerExceptions.Select(inner => inner.GetType()).ShouldBe([typeof(TaskCanceledException), typeof(InvalidOperationException)]); + + var mixed = new AggregateException(new OperationCanceledException(), Boom); + + Result.Catch(int () => throw mixed).TryGetValue(out _, out var valued).ShouldBeFalse(); + valued.ShouldBeSameAs(mixed); + Result.Catch(void () => throw mixed).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(mixed); + Result.Catch(() => throw mixed).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(mixed); + Result.Catch(void () => throw mixed).TryGetError(out var narrowedCommand).ShouldBeTrue(); + narrowedCommand.ShouldBeSameAs(mixed); + + (await Result.CatchAsync(async Task () => { await Task.Yield(); throw mixed; })).TryGetValue(out _, out var later).ShouldBeFalse(); + later.ShouldBeSameAs(mixed); + (await Result.CatchAsync(async () => { await Task.Yield(); throw mixed; })).TryGetError(out var laterCommand).ShouldBeTrue(); + laterCommand.ShouldBeSameAs(mixed); + } + + /// + /// A timeout is the error wrapped or not (decision 46). An aggregate that holds one, alone or beside + /// cancellations, is not a cancellation alone, and is the error as it was thrown. + /// + [Fact] + public async Task A_wrapped_timeout_is_the_error() + { + Result.Catch(() => Task.FromException(Timeout).Result).TryGetValue(out _, out var read).ShouldBeFalse(); + read.ShouldBeOfType().InnerException.ShouldBeSameAs(Timeout); + + var beside = new AggregateException(new OperationCanceledException(), Timeout); + Result.Catch(void () => throw beside).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(beside); + Result.Catch(() => throw beside).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(beside); + (await Result.CatchAsync(async Task () => { await Task.Yield(); throw beside; })).TryGetValue(out _, out var later).ShouldBeFalse(); + later.ShouldBeSameAs(beside); + } + + /// + /// .Result and Wait() on a cancelled task throw a new + /// naming the task, without the exception the task was cancelled with (measured on .NET 10): for an + /// call whose Timeout elapsed, the is gone + /// from what is thrown. It is still the task's, and the timeout is the error, as CatchAsync answers it. + /// + [Fact] + public void An_HttpClient_timeout_read_through_Result_is_the_error() + { + using var client = new HttpClient(new WaitingHandler()) { Timeout = TimeSpan.FromMilliseconds(1) }; + + var fetched = Result.Catch(() => client.GetByteArrayAsync(new Uri("http://timeout.test/")).Result); + + fetched.TryGetValue(out _, out var error).ShouldBeFalse(); + var thrown = error.ShouldBeOfType().InnerException.ShouldBeOfType(); + thrown.InnerException.ShouldBeNull("the runtime's own exception for a cancelled task holds no inner exception"); + + Should.Throw(() => Result.Catch(() => client.GetByteArrayAsync(new Uri("http://timeout.test/"), new CancellationToken(canceled: true)).Result)); + } + private static readonly TaskCanceledException Timeout = new("The request was canceled due to the configured HttpClient.Timeout elapsing.", new TimeoutException()); private static async Task TimesOutLaterAsync() From e5e1edd2e6401a9bb377d40cee25e286341ca9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:58:42 +0200 Subject: [PATCH 088/147] docs: CatchAsync's error is what await throws, the first of a WhenAll's exceptions, and the task holds them all A task that faulted with several exceptions, as Task.WhenAll's does when more than one operation fails, throws the first when awaited, so that is CatchAsync's error, and a narrowed CatchAsync looks at that one alone. That is await's semantics, kept on purpose: the error is what try { await task; } catch (Exception e) would catch. The XML docs, the result page and the base README say so, and that the task's Exception holds every failure; the page shows holding the task, compiled and checked. A test pins the behaviour in every overload. Co-Authored-By: Claude Opus 5.5 --- docs/result.md | 11 +++++++ src/CodoMetis.TypeKit/README.md | 4 ++- src/CodoMetis.TypeKit/ResultExtensions.cs | 9 +++++ .../Docs/ResultSamples.cs | 26 +++++++++++++++ .../ResultCatchTests.cs | 33 +++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/result.md b/docs/result.md index c0c237b..65a3c2a 100644 --- a/docs/result.md +++ b/docs/result.md @@ -203,6 +203,17 @@ how `HttpClient` reports its `Timeout` elapsing, the call failing, and `.Result` error too. `Map`, `Bind` and the rest are unchanged: a selector that throws propagates. +`CatchAsync`'s error is what `await` throws, the exception `try { await task; } catch (Exception e)` +would catch. A task that faulted with several exceptions, as `Task.WhenAll`'s does when more than one +operation fails, throws the first of them, and a narrowed `CatchAsync` looks at that one alone. The +task's `Exception` holds them all, so where every failure matters, hold the task: + +```csharp +Task both = Task.WhenAll(File.WriteAllTextAsync(draft, "a"), File.WriteAllTextAsync(copy, "b")); +Result saved = await Result.CatchAsync(() => both); // Error: DirectoryNotFoundException, the first of the two +int failures = both.Exception?.InnerExceptions.Count ?? 0; // 2, every failure is on the task +``` + ## Query syntax ```csharp diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index f2f5c28..039e238 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -192,7 +192,9 @@ type says what was expected to fail; both type arguments are spelled, since C# i and `Catch(() => { … })` with one is the command form, for a call without a value: given one that returns a value, which it would discard, it is a compile error naming the valued form. `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error -too. An `OperationCanceledException` passes through all of them, since a cancellation is not an +too, and the error is what `await` throws: for a task that faulted with several exceptions, as +`Task.WhenAll`'s does, the first of them, while the task's `Exception` holds them all, so hold the +task where every failure matters. An `OperationCanceledException` passes through all of them, since a cancellation is not an outcome and `Recover` would run on it as on a failure, and so does an `AggregateException` that holds cancellations alone, as `Wait()` and `.Result` throw for a cancelled task; one that holds anything beside them is the error, as it was thrown. The exception is a timeout: `HttpClient` reports its diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 2431d6f..47a8ae4 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -287,6 +287,7 @@ private static void RefuseTask(string method) /// task threw as the error, whether before or after its first await. /// /// + /// /// An passes through, so a cancelled token surfaces as /// cancellation, and so does an of cancellations alone; a timeout, /// which throws as a cancellation over a @@ -294,6 +295,14 @@ private static void RefuseTask(string method) /// needs .AsTask() or an async lambda: a ValueTask overload beside this one is /// ambiguous for every async lambda. /// See for cancellations, timeouts and null. + /// + /// + /// The error is what await throws, the exception try { await task; } catch (Exception e) + /// catches, in every overload: for a task that faulted with several exceptions, as + /// Task.WhenAll's does when more than one operation fails, the first of them, and a narrowed + /// overload looks at that one alone. The task's holds them all: where + /// every failure matters, hold the task, and read it once the result is an error. + /// /// /// The asynchronous call whose outcome is wanted. /// The type of the value. diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs index 07d04a4..3db6330 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs @@ -190,6 +190,17 @@ public static (Result, CheckoutFault> Lines, Result Saved, int Failures)> CatchingAll(string draft, string copy) + { + // sample: result/catch-all + Task both = Task.WhenAll(File.WriteAllTextAsync(draft, "a"), File.WriteAllTextAsync(copy, "b")); + Result saved = await Result.CatchAsync(() => both); // Error: DirectoryNotFoundException, the first of the two + int failures = both.Exception?.InnerExceptions.Count ?? 0; // 2, every failure is on the task + // end sample + + return (saved, failures); + } + public static Result Query(Checkout checkout, CartId cartId) { // sample: result/query @@ -330,6 +341,21 @@ public async Task Catch_answers_what_the_comments_say() } } + [Fact] + public async Task CatchAsync_over_WhenAll_answers_what_the_comments_say() + { + var missing = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var draft = Path.Combine(missing, "draft.txt"); + var copy = Path.Combine(missing, "copy.txt"); + + var (saved, failures) = await Usage.CatchingAll(draft, copy); + + saved.ShouldBeShownIn(Readme, result => result.Match(() => "Success", error => $"Error: {error.GetType().Name}"), "await Result.CatchAsync(() => both)"); + failures.ShouldBeShownIn(Readme, count => count.ToString(System.Globalization.CultureInfo.InvariantCulture), "both.Exception?.InnerExceptions.Count ?? 0"); + saved.TryGetError(out var first).ShouldBeTrue(); + first.Message.ShouldContain("draft.txt", Case.Sensitive, "the first operation's failure, not the second's"); + } + [Fact] public void Query_syntax_binds_each_step() { diff --git a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs index 4ffcd58..04f41f9 100644 --- a/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/ResultCatchTests.cs @@ -168,6 +168,39 @@ public async Task CatchAsync_catches_an_exception_thrown_after_the_first_await() value.ShouldBe(7); } + /// + /// CatchAsync keeps await's semantics: the error is what + /// try { await task; } catch (Exception e) catches, so for a task that faulted with several + /// exceptions, as Task.WhenAll's does, the first of them, and a narrowed form looks at that one + /// alone. The task's holds them all, which is what a caller holds the + /// task for where every failure matters. + /// + [Fact] + public async Task CatchAsync_over_a_task_with_several_exceptions_answers_the_one_await_throws() + { + var first = new InvalidOperationException("first"); + var second = new FormatException("second"); + + var both = Task.WhenAll(Task.FromException(first), Task.FromException(second)); + Exception? awaited = null; + try { await both; } + catch (Exception exception) { awaited = exception; } + + (await Result.CatchAsync(() => both)).TryGetError(out var error).ShouldBeTrue(); + error.ShouldBeSameAs(first); + error.ShouldBeSameAs(awaited); + both.Exception!.InnerExceptions.ShouldBe([first, second], "every failure is on the task"); + + var values = Task.WhenAll(Task.FromException(first), Task.FromException(second)); + (await Result.CatchAsync(() => values)).TryGetValue(out _, out var valued).ShouldBeFalse(); + valued.ShouldBeSameAs(first); + (await Result.CatchAsync(() => values)).TryGetValue(out _, out var narrowed).ShouldBeFalse(); + narrowed.ShouldBeSameAs(first); + (await Should.ThrowAsync(() => Result.CatchAsync(() => values))).ShouldBeSameAs(first, "the second exception is never looked at"); + (await Result.CatchAsync(() => both)).TryGetError(out var command).ShouldBeTrue(); + command.ShouldBeSameAs(first); + } + [Fact] public async Task CatchAsync_lets_a_cancelled_token_through_as_cancellation() { From bd4b17c865469a51f56c3304d755a4803ec68ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:05:04 +0200 Subject: [PATCH 089/147] docs: an asynchronous callback runs where the task it waited for completed, and nothing resumes on the caller's context Every await in the base package is ConfigureAwait(false), so a callback after a pending task runs without the caller's SynchronizationContext, and one after a completed task runs at once on it: in Blazor Server or WPF a callback that touches dispatcher-bound state worked or broke by timing. ConfigureAwait(false) stays, since dropping it would hang a caller that blocks on a chain today, and decision 62 records the contract with what was measured. The Result and Option class remarks, the result and option pages (each with a compiled example of awaiting first and continuing synchronously), the base README and the design note say so. AsynchronousContextTests holds every public member that returns a task, completeness-checked by reflection, to both halves: a callback after a pending task does not see the caller's context, one after a completed task does, and nothing is posted to a caller's context that, like a blocked UI thread, never runs it. Co-Authored-By: Claude Opus 5.5 --- ...lbacks-run-where-awaited-task-completed.md | 35 +++ design/adr/README.md | 1 + design/option-result.md | 9 + docs/option.md | 14 + docs/result.md | 20 ++ src/CodoMetis.TypeKit/OptionExtensions.cs | 7 + src/CodoMetis.TypeKit/README.md | 11 +- src/CodoMetis.TypeKit/ResultExtensions.cs | 14 + .../Docs/OptionSamples.cs | 25 ++ .../Docs/ResultSamples.cs | 21 ++ .../AsynchronousContextTests.cs | 291 ++++++++++++++++++ 11 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 design/adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md create mode 100644 test/CodoMetis.TypeKit.Tests/AsynchronousContextTests.cs diff --git a/design/adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md b/design/adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md new file mode 100644 index 0000000..faa04ba --- /dev/null +++ b/design/adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md @@ -0,0 +1,35 @@ +# 0062. An asynchronous callback runs where the task it waited for completed + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [Option and Result](../option-result.md), +[decision 30](0030-asynchronous-callbacks-return-task.md). + +Every `await` in the base package is `ConfigureAwait(false)`, about fifty of them: the continuations +of a pending `Option` or `Result`, `TraverseAsync`, `CatchAsync`, and the asynchronous forms on a known +result, which await the task their callback returns. Measured with a `SynchronizationContext` +installed by the caller: `await SlowResult().MapAsync(x => …)` ran the selector without the caller's +context while the result was still pending, and on it, inline, when the task had already completed; +the caller's own `await` returned to its context either way. In a Blazor Server component or a WPF +window, a callback that touches state bound to the renderer or the dispatcher therefore works or fails +by timing. + +`ConfigureAwait(false)` stays, and the behaviour is the documented contract: a callback runs where the +task it waits for completed, at once on the caller's thread and context when that task had already +completed, and otherwise wherever it completed, without the caller's context. In a UI or a Blazor +component, await the task and call the synchronous combinator on what it answers, or marshal inside +the callback. + +Dropping it would resume every continuation on the caller's context, and a caller that blocks on a +chain with `.Result`, `.Wait()` or `GetAwaiter().GetResult()` on a single-threaded context, which +works today, would hang: the continuation is posted to the thread that is waiting for it. Measured +with a caller's context that never runs what is posted to it, as a blocked UI thread does not: every +member that returns a task completes with `ConfigureAwait(false)`, and with it removed from one +continuation that continuation is posted and the chain never completes. A context-capturing overload +beside each member would double the surface for what the caller's own `await` before a synchronous +combinator already does. + +`AsynchronousContextTests` holds every public member of the base package that returns a task to both +halves, completeness-checked by reflection: a callback after a pending task does not run on the +caller's context, one after a completed task runs on it, and nothing posts to it. diff --git a/design/adr/README.md b/design/adr/README.md index c9c744b..4fa8912 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -65,3 +65,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0056](0056-value-translated-only-through-typekit-converter.md) | `.Value` is translated only on a column TypeKit's converter stores | 2026-10-13 | accepted | | [0057](0057-hand-written-ordering-refused-in-query.md) | A query that orders by a hand-written `CompareTo` is refused | 2026-10-13 | accepted | | [0058](0058-integer-key-follows-ef-rule.md) | A value-object key is generated wherever EF generates its wrapped type's | 2026-10-13 | accepted | +| [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | diff --git a/design/option-result.md b/design/option-result.md index e9e730d..446657f 100644 --- a/design/option-result.md +++ b/design/option-result.md @@ -65,6 +65,15 @@ is the value-less shape. Anything that turns this into a second LanguageExt is o throws. Callbacks return `Task` ([decision 30](adr/0030-asynchronous-callbacks-return-task.md)): `TapAsync` and `TapErrorAsync` take a `Func<…, Task>` beside the `Action`, so an `async` lambda is awaited; a `ValueTask` one needs `.AsTask()`. +- **A callback runs where the task it waited for completed (decided 2026-10-13, + [decision 62](adr/0062-asynchronous-callbacks-run-where-awaited-task-completed.md)).** Every `await` + in the package is `ConfigureAwait(false)`, so nothing resumes on the caller's + `SynchronizationContext` and its awaits cannot deadlock a caller blocked on a chain. The price, measured: a + callback after a pending task runs without the caller's context, one after a completed task runs at + once on it, so in a UI or a Blazor component a callback that touches dispatcher-bound state works or + fails by timing. That is documented rather than changed: await the task and call the synchronous + combinator, or marshal inside the callback. `AsynchronousContextTests` holds every public member + that returns a task to it. **Decided 2026-09-27: an uninitialized `Result` throws.** - A `default` result is `State == Uninitialized`. If the branching members tested only diff --git a/docs/option.md b/docs/option.md index 8047a66..628fa52 100644 --- a/docs/option.md +++ b/docs/option.md @@ -82,6 +82,20 @@ answers `Some` for and drops the `None`s, a filter and a map in one, lazily as a a sequence of options, `Choose()` alone keeps the values they hold. `Select`, `SelectMany` and `Where` make query syntax work, and each `from` after the first sees the earlier ranges, as above. +## A pending option + +A lookup that is still running is a `Task>`, and `MapAsync`, `BindAsync`, `FilterAsync`, +`TapAsync` and `ToResultAsync` continue it as [Result's async chains](result.md#async-chains) continue +a pending result, awaited once at the end. A callback there runs where the task it waits for completed: +at once on the caller's synchronization context when the lookup had already finished, and without it, +usually on a thread-pool thread, when it had not ([Where a callback runs](result.md#where-a-callback-runs)). +In a Blazor component or a WPF window, await the lookup and continue with the synchronous members: + +```csharp +Option loaded = await shop.FindAsync(ann.Id); // the await returns to the caller's context +string shown = loaded.Map(customer => customer.Name).Or("unknown"); // "Ann", the selector on that context too +``` + ## At the boundary An option is not a wire type, not a column and not a route parameter: System.Text.Json refuses it in diff --git a/docs/result.md b/docs/result.md index 65a3c2a..7b6b840 100644 --- a/docs/result.md +++ b/docs/result.md @@ -149,6 +149,26 @@ throws reaches the chain's `await`. A callback that returns a `ValueTask` needs `async` lambda: `TapAsync` and `TapErrorAsync` would otherwise bind to their synchronous overload and not wait, and `MapAsync` would hold the `ValueTask` as the value. +## Where a callback runs + +Every `await` inside the package is `ConfigureAwait(false)`, so nothing resumes on the caller's +`SynchronizationContext`, and a caller that blocks on a chain with `.Result` or `.Wait()` is not +deadlocked by the package's own awaits. A callback therefore runs where the task it waits for completed. When that task has +already completed, the callback runs at once, on the caller's thread and context; when it is still +pending, the callback runs wherever the task completes, usually a thread-pool thread, without the +caller's context. That holds for every continuation of a pending result, for `TraverseAsync` after its +first operation, and for every step after one that waited. The caller's own `await` of the chain +returns to its context as always. + +In a Blazor component or a WPF window, a callback that touches state bound to the renderer or the +dispatcher would work or fail by timing. There, await the task and continue with the synchronous +combinators, or marshal inside the callback (`InvokeAsync`, `Dispatcher.Invoke`): + +```csharp +Result found = await checkout.FindCartAsync(cartId); // the await returns to the caller's context +string lineCount = found.Map(cart => $"{cart.Lines.Count} lines").Match(text => text, fault => $"refused: {fault}"); // "2 lines", the selector on that context too +``` + ## Collecting results ```csharp diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index d5b5e57..7c58cb3 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -6,6 +6,13 @@ namespace CodoMetis.TypeKit; /// Creates instances, and adds the LINQ vocabulary, the zips and the /// conversions to and from nullables, sequences and results. /// +/// +/// The continuations of a pending option (MapAsync, BindAsync, FilterAsync, +/// TapAsync) never resume on the caller's , as +/// 's do not: a callback runs at once, on the caller's context, when the option was +/// already known, and otherwise wherever its task completed, without that context. In a UI or a Blazor +/// component, await the option and call the synchronous combinator on it, or marshal inside the callback. +/// public static class Option { /// Creates an option that holds . diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 039e238..344da93 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -80,7 +80,9 @@ Result active = await repository.FindByEmailAsync(email) `MapAsync`, `BindAsync`, `FilterAsync`, `TapAsync` and `ToResultAsync` continue a pending option, and an option has `MapAsync`, `BindAsync` and `TapAsync` over callbacks that return a `Task`. Each checks -its delegates, and `ToResultAsync` its error, before the option is known. +its delegates, and `ToResultAsync` its error, before the option is known. A callback runs where the +task it waits for completed, not on the caller's synchronization context when that task was still +pending, as a pending result's does (below). ## Result<T, TError> and Result<TError> @@ -174,7 +176,12 @@ The asynchronous steps take callbacks that return a `Task`, and wait for them: `.TapErrorAsync(fault => audit.RecordAsync(fault))` has recorded the fault when the chain's `await` returns, and what it throws reaches that `await`. A callback that returns a `ValueTask` needs `.AsTask()`, or an `async` lambda: `TapAsync` and `TapErrorAsync` would bind it to their synchronous -overload and not wait for it, and `MapAsync` would hold the `ValueTask` as the value. +overload and not wait for it, and `MapAsync` would hold the `ValueTask` as the value. Nothing in the +package resumes on the caller's synchronization context, so its awaits never deadlock a caller that +blocks on a chain, and a callback runs where the task it waits for completed: at once, on the caller's +context, when that task had already completed, and without it, usually on a thread-pool thread, when +it was still pending. In a UI or a Blazor component, await the task and continue with the synchronous +combinators ([where a callback runs](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/result.md#where-a-callback-runs)). **Exceptions as outcomes.** A call that throws has no way into a `Result` except a `try`/`catch` written by hand. `Result.Catch` runs the call and answers its value, or the exception as the error. diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 47a8ae4..836b8c2 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -6,8 +6,22 @@ namespace CodoMetis.TypeKit; /// continuations of a result that is still being produced (Task<Result<…>>). /// /// +/// /// Everything here stops at the first error, in argument or sequence order. Nothing collects /// errors: that is a validation applicative, which this package leaves out on purpose. +/// +/// +/// The asynchronous members never resume on the caller's : every +/// await in them is ConfigureAwait(false), so they cannot deadlock a caller that blocks +/// on a chain. A callback therefore runs where the task it waits for completed: at once, on the +/// caller's thread and context, when that task had already completed, and otherwise wherever it +/// completed, usually a thread-pool thread without the caller's context. That covers a continuation of +/// a pending result (MapAsync, BindAsync, TapAsync and the rest), every operation +/// of TraverseAsync after the first, and every step after one that waited. The caller's own +/// await of the chain returns to its context as usual. In a UI or a Blazor component, where a +/// callback touches state bound to the dispatcher or the renderer, await the task and call the +/// synchronous combinator on what it answers, or marshal inside the callback. +/// /// public static class Result { diff --git a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs index 7e54cb0..3cd37b2 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs @@ -89,6 +89,21 @@ from address in customer.Address // a later from sees the earlier one return (discount, country, label, seen, greeting, nicknames.ToList(), known.ToList()); } + public Task> FindAsync(CustomerId id) => Task.FromResult(Customers.GetValueOrNone(id)); + + public async Task Pending() + { + var shop = this; + var ann = Ann; + + // sample: option/pending + Option loaded = await shop.FindAsync(ann.Id); // the await returns to the caller's context + string shown = loaded.Map(customer => customer.Name).Or("unknown"); // "Ann", the selector on that context too + // end sample + + return shown; + } + // sample: option/boundary static Customer FromDto(CustomerDto dto) => new(CustomerId.New(), dto.Name, "standard", dto.Nickname.ToOption(), Option.None()); @@ -169,6 +184,16 @@ public void Combining_answers_what_the_comments_say() known.ShouldBe([shop.Ann], "the unknown id was dropped"); } + [Fact] + public async Task A_pending_option_answers_what_the_comment_says() + { + var shop = new Shop(); + + var shown = await shop.Pending(); + + shown.ShouldBeShownIn(Readme, Quoted, "loaded.Map(customer => customer.Name).Or(\"unknown\")"); + } + [Fact] public void The_boundary_converts_as_the_page_says() { diff --git a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs index 3db6330..ad22515 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/ResultSamples.cs @@ -149,6 +149,16 @@ public static async Task PipelineAsync(Checkout checkout, CartId cartId, return asyncLedger; } + public static async Task OnTheCallersContext(Checkout checkout, CartId cartId) + { + // sample: result/context + Result found = await checkout.FindCartAsync(cartId); // the await returns to the caller's context + string lineCount = found.Map(cart => $"{cart.Lines.Count} lines").Match(text => text, fault => $"refused: {fault}"); // "2 lines", the selector on that context too + // end sample + + return lineCount; + } + public static (Result, CheckoutFault> Lines, Result, CheckoutFault> All, Result Amount, Result Bulk, string Summary, string Stopped) Collect( Checkout checkout, Cart cart, List requested, List refused, List> reservations, Result unitPrice, Result quantity) { @@ -281,6 +291,17 @@ public async Task The_async_chain_answers_what_the_comment_says() audit.ShouldBe(["not charged: CartEmpty"]); } + [Fact] + public async Task Continuing_after_the_await_answers_what_the_comment_says() + { + var checkout = new Checkout(); + + var lineCount = await Usage.OnTheCallersContext(checkout, checkout.Cart.Id); + + lineCount.ShouldBeShownIn(Readme, Quoted, "found.Map(cart => $\"{cart.Lines.Count} lines\").Match(text => text, fault => $\"refused: {fault}\")"); + (await Usage.OnTheCallersContext(checkout, CartId.New())).ShouldBe("refused: CartUnknown"); + } + [Fact] public void Collecting_answers_what_the_comments_say() { diff --git a/test/CodoMetis.TypeKit.Tests/AsynchronousContextTests.cs b/test/CodoMetis.TypeKit.Tests/AsynchronousContextTests.cs new file mode 100644 index 0000000..9b2ca8b --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/AsynchronousContextTests.cs @@ -0,0 +1,291 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// Where the callbacks of the asynchronous members run, and that nothing resumes on the caller's +/// (decision 62). +/// +/// +/// +/// Every await in the package is ConfigureAwait(false). A callback therefore runs where the task +/// it waited for completed: at once, on the caller's thread and context, when that task had already +/// completed, and without the caller's context, wherever the task completed, when it was still pending. +/// In a UI or a Blazor component the second is the one that breaks, by timing, so it is documented and +/// pinned here, member by member: a change to it is a decision, not an accident. +/// +/// +/// The caller's context here never runs what is posted to it, as a thread blocked on the chain +/// (.Result, .Wait(), GetAwaiter().GetResult()) never does. A member that resumed on it +/// would hang that caller, which is why ConfigureAwait(false) stays; such a post fails here at once. +/// The cases are written by hand, and the completeness test holds them to every public member of the +/// assembly that returns a task. +/// +/// +public sealed class AsynchronousContextTests +{ + private static readonly Option Some = Option.Some(1); + + private static readonly Result Ok = Result.Success(); + private static readonly Result Failed = Result.Error("e"); + + private static readonly Result Value = Result.Success(1); + private static readonly Result Error = Result.Error("e"); + + private static readonly Dictionary> Cases = new() + { + ["Result.MapAsync(Task>, Func)"] = p => p.First(Value).MapAsync(x => p.Seen(x)), + ["Result.MapAsync(Task>, Func>)"] = p => p.First(Value).MapAsync(x => p.Later(p.Seen(x))), + ["Result.BindAsync(Task>, Func>)"] = p => p.First(Value).BindAsync(x => p.Seen(Value)), + ["Result.BindAsync(Task>, Func>>)"] = p => p.First(Value).BindAsync(x => p.Later(p.Seen(Value))), + ["Result.BindAsync(Task>, Func>)"] = p => p.First(Value).BindAsync(x => p.Seen(Ok)), + ["Result.BindAsync(Task>, Func>>)"] = p => p.First(Value).BindAsync(x => p.Later(p.Seen(Ok))), + ["Result.MapErrorAsync(Task>, Func)"] = p => p.First(Error).MapErrorAsync(e => p.Seen(e.Length)), + ["Result.RecoverAsync(Task>, Func>)"] = p => p.First(Error).RecoverAsync(e => p.Seen(Value)), + ["Result.RecoverAsync(Task>, Func>>)"] = p => p.First(Error).RecoverAsync(e => p.Later(p.Seen(Value))), + ["Result.EnsureAsync(Task>, Func, TError)"] = p => p.First(Value).EnsureAsync(x => p.Seen(true), "e"), + ["Result.TapErrorAsync(Task>, Action)"] = p => p.First(Error).TapErrorAsync(e => p.Seen(e)), + ["Result.TapErrorAsync(Task>, Func)"] = p => p.First(Error).TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.TapAsync(Task>, Action)"] = p => p.First(Value).TapAsync(x => p.Seen(x)), + ["Result.TapAsync(Task>, Func)"] = p => p.First(Value).TapAsync(x => p.Later(p.Seen(x))), + + ["Result.MapAsync(Task>, Func)"] = p => p.First(Ok).MapAsync(() => p.Seen(1)), + ["Result.MapAsync(Task>, Func>)"] = p => p.First(Ok).MapAsync(() => p.Later(p.Seen(1))), + ["Result.BindAsync(Task>, Func>)"] = p => p.First(Ok).BindAsync(() => p.Seen(Ok)), + ["Result.BindAsync(Task>, Func>>)"] = p => p.First(Ok).BindAsync(() => p.Later(p.Seen(Ok))), + ["Result.MapErrorAsync(Task>, Func)"] = p => p.First(Failed).MapErrorAsync(e => p.Seen(e.Length)), + ["Result.RecoverAsync(Task>, Func>)"] = p => p.First(Failed).RecoverAsync(e => p.Seen(Ok)), + ["Result.RecoverAsync(Task>, Func>>)"] = p => p.First(Failed).RecoverAsync(e => p.Later(p.Seen(Ok))), + ["Result.TapErrorAsync(Task>, Action)"] = p => p.First(Failed).TapErrorAsync(e => p.Seen(e)), + ["Result.TapErrorAsync(Task>, Func)"] = p => p.First(Failed).TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.TapAsync(Task>, Action)"] = p => p.First(Ok).TapAsync(() => p.Seen(0)), + ["Result.TapAsync(Task>, Func)"] = p => p.First(Ok).TapAsync(() => p.Later(p.Seen(0))), + + // The first element's operation is what the second element's selector waits for. + ["Result.TraverseAsync(IEnumerable, Func>>)"] = + p => new[] { 1, 2 }.TraverseAsync(x => x == 1 ? p.First(Value) : p.Later(p.Seen(Value))), + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = + p => new[] { 1, 2 }.TraverseAsync((x, _) => x == 1 ? p.First(Value) : p.Later(p.Seen(Value)), CancellationToken.None), + ["Result.TraverseAsync(IEnumerable, Func>>)"] = + p => new[] { 1, 2 }.TraverseAsync(x => x == 1 ? p.First(Ok) : p.Later(p.Seen(Ok))), + ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken)"] = + p => new[] { 1, 2 }.TraverseAsync((x, _) => x == 1 ? p.First(Ok) : p.Later(p.Seen(Ok)), CancellationToken.None), + + // The delegate runs at once, on the caller's context; the task it returns is awaited without it. + ["Result.CatchAsync(Func>)"] = p => Result.CatchAsync(() => p.Later(p.Seen(1))), + ["Result.CatchAsync(Func)"] = p => Result.CatchAsync(() => (Task)p.Later(p.Seen(1))), + ["Result.CatchAsync(Func>)"] = p => Result.CatchAsync(() => p.Later(p.Seen(1))), + ["Result.CatchAsync(Func)"] = p => Result.CatchAsync(() => (Task)p.Later(p.Seen(1))), + + ["Option.MapAsync(Task>, Func)"] = p => p.First(Some).MapAsync(x => p.Seen(x)), + ["Option.MapAsync(Task>, Func>)"] = p => p.First(Some).MapAsync(x => p.Later(p.Seen(x))), + ["Option.BindAsync(Task>, Func>)"] = p => p.First(Some).BindAsync(x => p.Seen(Some)), + ["Option.BindAsync(Task>, Func>>)"] = p => p.First(Some).BindAsync(x => p.Later(p.Seen(Some))), + ["Option.FilterAsync(Task>, Func)"] = p => p.First(Some).FilterAsync(x => p.Seen(true)), + ["Option.TapAsync(Task>, Action)"] = p => p.First(Some).TapAsync(x => p.Seen(x)), + ["Option.TapAsync(Task>, Func)"] = p => p.First(Some).TapAsync(x => p.Later(p.Seen(x))), + ["Option.ToResultAsync(Task>, TError)"] = p => p.First(Some).ToResultAsync("e"), + + // On a known result or option the callback runs at once; what it returns is awaited without the context. + ["Option.MapAsync(Func>)"] = p => Some.MapAsync(x => p.Later(p.Seen(x))), + ["Option.BindAsync(Func>>)"] = p => Some.BindAsync(x => p.Later(p.Seen(Some))), + ["Option.TapAsync(Func)"] = p => Some.TapAsync(x => p.Later(p.Seen(x))), + + ["Result.TapAsync(Func)"] = p => Ok.TapAsync(() => p.Later(p.Seen(0))), + ["Result.TapErrorAsync(Func)"] = p => Failed.TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.MapAsync(Func>)"] = p => Ok.MapAsync(() => p.Later(p.Seen(1))), + ["Result.BindAsync(Func>>)"] = p => Ok.BindAsync(() => p.Later(p.Seen(Ok))), + ["Result.RecoverAsync(Func>>)"] = p => Failed.RecoverAsync(e => p.Later(p.Seen(Ok))), + + ["Result.TapAsync(Func)"] = p => Value.TapAsync(x => p.Later(p.Seen(x))), + ["Result.TapErrorAsync(Func)"] = p => Error.TapErrorAsync(e => p.Later(p.Seen(e))), + ["Result.MapAsync(Func>)"] = p => Value.MapAsync(x => p.Later(p.Seen(x))), + ["Result.BindAsync(Func>>)"] = p => Value.BindAsync(x => p.Later(p.Seen(Value))), + ["Result.BindAsync(Func>>)"] = p => Value.BindAsync(x => p.Later(p.Seen(Ok))), + ["Result.RecoverAsync(Func>>)"] = p => Error.RecoverAsync(e => p.Later(p.Seen(Value))), + }; + + /// Members that take no callback: only that nothing resumes on the caller's context is checked. + private static readonly HashSet WithoutCallback = ["Option.ToResultAsync(Task>, TError)"]; + + public static TheoryData CaseNames => [.. Cases.Keys]; + + [Theory] + [MemberData(nameof(CaseNames))] + public async Task A_callback_runs_where_the_task_it_waited_for_completed_and_nothing_resumes_on_the_caller_s_context(string member) + { + foreach (var pending in new[] { true, false }) + { + var mode = pending ? "the task it waits for still pending" : "the task it waits for already completed"; + var probe = new Probe(pending); + var caller = new BlockedContext(); + + var chain = Start(Cases[member], probe, caller); + probe.Open(); + + var first = await Task.WhenAny(chain, caller.Posted, Task.Delay(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken)); + + caller.Posts.ShouldBe(0, $"{member}, {mode}, resumed on the caller's context, which a caller blocked on the chain never runs: it hangs."); + first.ShouldBeSameAs(chain, $"{member}, {mode}, did not complete."); + await chain; + + if (WithoutCallback.Contains(member)) continue; + + probe.Called.ShouldBeTrue($"{member}, {mode}: the case never reached its callback."); + if (probe.Waited) + probe.Context.ShouldNotBeSameAs(caller, $"{member}: a callback after a pending task runs where that task completed, without the caller's context."); + else + probe.Context.ShouldBeSameAs(caller, $"{member}: a callback with nothing pending before it runs at once, on the caller's context."); + } + } + + /// The documented case, spelled out: a selector after a pending result does not see the caller's context, and the caller's own await returns to it. + [Fact] + public async Task A_selector_after_a_pending_result_runs_without_the_caller_s_context_and_the_caller_s_await_returns_to_it() + { + var caller = new RunningContext(); + SynchronizationContext? selector = null; + SynchronizationContext? after = null; + + await caller.Run(async () => + { + var gate = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var chain = gate.Task.MapAsync(x => { selector = SynchronizationContext.Current; return x + 1; }); + _ = Task.Run(() => gate.SetResult(Value)); + + (await chain).Match(x => x, _ => -1).ShouldBe(2); + after = SynchronizationContext.Current; + }); + + selector.ShouldNotBeSameAs(caller, "the selector ran on the caller's context"); + after.ShouldBeSameAs(caller, "the caller's await captures its own context, whatever the package does"); + } + + [Fact] + public void Every_public_member_that_returns_a_task_has_a_case() + { + var members = AsynchronousMembers().ToList(); + + members.Count.ShouldBeGreaterThanOrEqualTo(50); + members.Except(Cases.Keys).ShouldBeEmpty("These public members return a task and have no case here."); + Cases.Keys.Except(members).ShouldBeEmpty("These cases name no public member of the assembly."); + } + + private static Task Start(Func chain, Probe probe, SynchronizationContext caller) + { + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(caller); + try + { + return chain(probe); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + } + + private static IEnumerable AsynchronousMembers() + { + var methods = + from type in typeof(Option<>).Assembly.GetExportedTypes() + where !type.Name.Contains('<') && type.GetCustomAttribute() is null + from method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + where typeof(Task).IsAssignableFrom(method.ReturnType) + where method.GetCustomAttribute() is not { IsError: true } + select (Type: type, Method: method, Signature: $"({string.Join(", ", method.GetParameters().Select(p => Describe(p.ParameterType)))})"); + + // Overloads that differ only in their type parameters (CatchAsync beside CatchAsync) + // are keyed with them, as in NullDelegateTests. + var byKey = methods.ToLookup(m => $"{Describe(m.Type)}.{m.Method.Name}{m.Signature}"); + + return from overloads in byKey + from m in overloads + let name = overloads.Count() > 1 && m.Method.IsGenericMethod + ? $"{m.Method.Name}<{string.Join(", ", m.Method.GetGenericArguments().Select(a => a.Name))}>" + : m.Method.Name + select $"{Describe(m.Type)}.{name}{m.Signature}"; + } + + private static string Describe(Type type) => + type.IsByRef ? Describe(type.GetElementType()!) + : type.IsGenericType ? $"{type.Name[..type.Name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(Describe))}>" + : type.Name; + + /// + /// Hands a case the task its callback waits for, pending or completed, and records where the callback ran. + /// Every task a callback returns stays pending until , so the await after it suspends. + /// + private sealed class Probe(bool pending) + { + private readonly TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Whether the case waited for a task that was still pending before its callback. + public bool Waited { get; private set; } + + public bool Called { get; private set; } + + public SynchronizationContext? Context { get; private set; } + + public Task First(T value) + { + Waited = pending; + return pending ? Later(value) : Task.FromResult(value); + } + + public async Task Later(T value) + { + await gate.Task.ConfigureAwait(false); + return value; + } + + public T Seen(T value) + { + Called = true; + Context = SynchronizationContext.Current; + return value; + } + + public void Open() => gate.SetResult(); + } + + /// A caller blocked on the chain: what is posted to its context never runs. + private sealed class BlockedContext : SynchronizationContext + { + private readonly TaskCompletionSource posted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int posts; + + public int Posts => Volatile.Read(ref posts); + + public Task Posted => posted.Task; + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref posts); + posted.TrySetResult(); + } + + public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException("Nothing sends to the caller's context."); + } + + /// A caller's context that runs what is posted to it, on the thread pool, as itself. + private sealed class RunningContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object? state) => + ThreadPool.QueueUserWorkItem(_ => + { + SetSynchronizationContext(this); + d(state); + }); + + /// Starts on this context, as a component's event handler starts on its renderer's. + public Task Run(Func body) + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(_ => started.SetResult(body()), null); + + return started.Task.Unwrap(); + } + } +} From 39633ffc165c214be7631aafc6e728fe6fd3e06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:07:01 +0200 Subject: [PATCH 090/147] fix: a value a Patch cannot read is refused at the patched property's path The converter reads the value with a nested JsonSerializer.Deserialize, which reports the path within that value, "$", and the serializer keeps a path it finds set: {"quantity":"x"} into a Patch gave Path "$" where an int property gives "$.quantity", and [{}, {"quantity":"x"}] gave "$" for "$[1].quantity", so a 400 for a bad PATCH field could not name the field. The converter throws the nested refusal again without a path, and the serializer sets the property's. The nested sentence is kept, a value object's own refusal among them, less the location the serializer appended to its default sentence, which named "$". Inside a patched object the path ends at the patched property, since a converter cannot extend the serializer's path. Co-Authored-By: Claude Opus 5.5 --- design/patch.md | 8 +++ docs/merge-patches.md | 5 +- src/CodoMetis.TypeKit/Patch.cs | 27 +++++++- .../Docs/MergePatchesSamples.cs | 2 +- test/CodoMetis.TypeKit.Tests/PatchTests.cs | 66 +++++++++++++++++++ 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/design/patch.md b/design/patch.md index 9d3ad9f..9b62a8a 100644 --- a/design/patch.md +++ b/design/patch.md @@ -18,6 +18,14 @@ package, is the one wire type there, and is hand-written, not generated. Measure - **Reading never refuses a null.** Null is a state of the wire format whatever `T` is; whether a clear is accepted is the application's rule, said in `Match`. A value object inside a patch is read by its own converter, so its rule applies there as anywhere else. +- **A refusal is at the patched property's path.** The converter reads the value with a nested + `JsonSerializer.Deserialize`, which sets the path within that value, `$`, and the serializer keeps + a path it finds set, so a client was told `$` for a bad field. The converter throws the refusal + again without a path, and the serializer sets the property's, `$.quantity` or `$[1].quantity`, as + for a property of `T`. The nested sentence is kept, a value object's own refusal among them, less + the location the serializer appended to its default sentence, which named `$`. Inside a patched + object the path ends at the patched property, since a converter cannot extend the serializer's + path (`PatchTests`). - **Writing an absent patch is refused**, since a converter cannot leave a property out; `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out instead. - **`ToString()` never prints the value**, as for `Option` and `Result`. diff --git a/docs/merge-patches.md b/docs/merge-patches.md index 4295faa..97dce64 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -96,8 +96,9 @@ rule: a value `Create` refuses is refused as anywhere else, and the refusal does JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web); // JsonException naming Email and NoAt, never the text ``` -In an endpoint, that is the 400 any body a value object refuses gets, with problem details where the -host adds `UseStatusCodePages()` ([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)). A `null` is +The exception's `Path` is the field's, `$.email`, as it is for a property that is no patch, so the +answer to the client can name the field. In an endpoint, that is the 400 any body a value object +refuses gets, with problem details where the host adds `UseStatusCodePages()` ([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)). A `null` is not a refusal but the `Null` state, which `Apply` turns into a fault of its own, so a `Patch` is never needed. diff --git a/src/CodoMetis.TypeKit/Patch.cs b/src/CodoMetis.TypeKit/Patch.cs index 1cccd6c..6de962e 100644 --- a/src/CodoMetis.TypeKit/Patch.cs +++ b/src/CodoMetis.TypeKit/Patch.cs @@ -231,7 +231,32 @@ public override Patch Read(ref Utf8JsonReader reader, Type typeToConvert, Jso { if (reader.TokenType == JsonTokenType.Null) return Patch.Null(); - return Patch.Value(JsonSerializer.Deserialize(ref reader, Contract(options))); + try + { + return Patch.Value(JsonSerializer.Deserialize(ref reader, Contract(options))); + } + catch (JsonException nested) when (nested.Path is not null) + { + throw AtThePatchedProperty(nested); + } + } + + /// + /// The nested call reads the value as a document of its own, so its refusal carries the path within + /// that document, $, and the serializer keeps a path it finds set: a client was told + /// $ where a property of gives $.quantity. Thrown again without + /// one, the serializer sets the patched property's path, $.quantity or $[1].quantity. + /// The nested refusal's sentence is kept, a value object's own among them, less the location the + /// nested call appended to the serializer's default sentence, which named $; its inner + /// exception is kept, the nested exception itself is not, since its message names $ too. + /// Nothing of the input is added. + /// + private static JsonException AtThePatchedProperty(JsonException nested) + { + var appended = $" Path: {nested.Path} | LineNumber: {nested.LineNumber} | BytePositionInLine: {nested.BytePositionInLine}."; + var message = nested.Message.EndsWith(appended, StringComparison.Ordinal) ? nested.Message[..^appended.Length] : nested.Message; + + return new JsonException(message, nested.InnerException); } public override void Write(Utf8JsonWriter writer, Patch value, JsonSerializerOptions options) diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs index acbcf1b..a9cdc69 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs @@ -224,7 +224,7 @@ public void Validating_a_patch_answers_what_the_comments_say() public void A_value_object_inside_a_patch_keeps_its_rule() { SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Deserialize("""{"email":"nobody"}""", JsonSerializerOptions.Web), Page, input: "nobody"); - Should.Throw(Usage.Refused); + Should.Throw(Usage.Refused).Path.ShouldBe("$.email", "the page says the path is the field's"); } [Fact] diff --git a/test/CodoMetis.TypeKit.Tests/PatchTests.cs b/test/CodoMetis.TypeKit.Tests/PatchTests.cs index d458b94..7e38504 100644 --- a/test/CodoMetis.TypeKit.Tests/PatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/PatchTests.cs @@ -161,6 +161,67 @@ public void A_type_with_its_own_converter_inside_a_patch_is_read_with_it_and_its refusal.Message.ShouldNotContain("abc"); } + /// + /// The converter reads the value with a nested call, which reports the path within the value it + /// reads, $, and the serializer kept it: an HTTP 400 for a bad field of a PATCH could not name + /// the field. The path is the patched property's now, as for a property of the wrapped type, and the + /// message names no other. + /// + [Theory] + [MemberData(nameof(Resolvers))] + public void A_value_the_wrapped_type_cannot_read_is_refused_at_the_property_s_path(string resolver) + { + var options = Options(resolver); + + var plain = Should.Throw(() => JsonSerializer.Deserialize("""{"quantity":"SECRET-7f3a"}""", options)); + var patch = Should.Throw(() => JsonSerializer.Deserialize("""{"quantity":"SECRET-7f3a"}""", options)); + + plain.Path.ShouldBe("$.quantity"); + patch.Path.ShouldBe(plain.Path); + patch.LineNumber.ShouldBe(plain.LineNumber); + patch.BytePositionInLine.ShouldBe(plain.BytePositionInLine); + patch.Message.ShouldStartWith("The JSON value could not be converted to System.Int32."); + patch.Message.ShouldNotContain("Path:"); + patch.Message.ShouldNotContain("SECRET"); + } + + /// A value object's own refusal is the message the caller sees, at the property's path. + [Theory] + [MemberData(nameof(Resolvers))] + public void A_value_object_s_refusal_inside_a_patch_keeps_its_message_at_the_property_s_path(string resolver) + { + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"name":"Ada","code":"abc"}""", Options(resolver))); + + refusal.Path.ShouldBe("$.code"); + refusal.Message.ShouldBe("Code refused the JSON value (NotUpperCase)."); + } + + /// In a list of patches the path names the element and its property. + [Theory] + [MemberData(nameof(Resolvers))] + public void A_refusal_in_an_array_of_patches_names_the_element(string resolver) + { + var options = Options(resolver); + + Should.Throw(() => JsonSerializer.Deserialize>("""[{}, {"quantity":"x"}]""", options)).Path.ShouldBe("$[1].quantity"); + Should.Throw(() => JsonSerializer.Deserialize>("""[{}, {"code":"abc"}]""", options)).Path.ShouldBe("$[1].code"); + } + + /// + /// A converter cannot extend the serializer's path, so inside a patched object the path ends at the + /// patched property: coarser than the property within, and never the nested call's `$.city`, which + /// names a property the document does not have at its root. + /// + [Theory] + [MemberData(nameof(Resolvers))] + public void Inside_a_patched_object_the_path_ends_at_the_patched_property(string resolver) + { + var refusal = Should.Throw(() => JsonSerializer.Deserialize("""{"address":{"city":5}}""", Options(resolver))); + + refusal.Path.ShouldBe("$.address"); + refusal.Message.ShouldNotContain("Path:"); + } + [Theory] [MemberData(nameof(Resolvers))] public void Writing_leaves_an_absent_patch_out_under_WhenWritingDefault_and_refuses_it_otherwise(string resolver) @@ -205,6 +266,9 @@ public sealed class CodeConverter : JsonConverter public sealed record Update(Patch Name, Patch Quantity, Patch Code, Patch Address); + /// The same field without a patch, whose refusal is the one a patch's should match. + public sealed record Plain(int Quantity); + public sealed record UpdateWithoutAddress(Patch Address); public sealed record Outgoing( @@ -217,6 +281,8 @@ public sealed record BareOutgoing(Patch Name); // A class a patch wraps is listed, since the patch's converter hides it from the generator; a primitive // and a type with its own converter need not be. [JsonSerializable(typeof(PatchTests.Update))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(PatchTests.Plain))] [JsonSerializable(typeof(PatchTests.Address))] [JsonSerializable(typeof(PatchTests.Outgoing))] [JsonSerializable(typeof(PatchTests.BareOutgoing))] From 2a9f6c87e7873ddfada5894247d36605e2e3ae6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:07:06 +0200 Subject: [PATCH 091/147] test: a blank fault detail and a null reason are refused, as Fault.Of documents Seeded by the review and unnoticed: Fault accepting a whitespace-only detail (IsNullOrWhiteSpace narrowed to Length == 0) and a null reason (the check removed). The XML documentation and the README promise both throws. Co-Authored-By: Claude Opus 5.5 --- test/CodoMetis.TypeKit.Tests/FaultTests.cs | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/CodoMetis.TypeKit.Tests/FaultTests.cs b/test/CodoMetis.TypeKit.Tests/FaultTests.cs index 0215b82..cf27349 100644 --- a/test/CodoMetis.TypeKit.Tests/FaultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/FaultTests.cs @@ -62,6 +62,38 @@ public void A_bare_reason_is_not_a_fault(string member, string error) public void Comparing_the_reason_or_two_faults_compiles(string member) => ConsumerCompilation.Of(Consumer(member)).Problems().ShouldBeEmpty(); + /// + /// No detail is null, so a blank one is a mistake, and refused where it is made: a detail of + /// spaces is written into a problem's detail as a sentence that says nothing. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void A_blank_detail_is_refused(string detail) + { + var refusal = Should.Throw(() => Fault.Of(OrderFault.NotFound, detail)); + + refusal.ParamName.ShouldBe("detail"); + refusal.ShouldNotBeOfType(); + } + + /// A reason that is a class, a closed set's member, can be null, and a fault of no reason is refused. + [Fact] + public void A_null_reason_is_refused() + { + var refusal = Should.Throw(() => Fault.Of(null!)); + + refusal.ParamName.ShouldBe("reason"); + } + + [Fact] + public void A_fault_without_a_detail_has_none() + { + Fault.Of(OrderFault.NotFound).Detail.ShouldBeNull(); + Fault.Of(OrderFault.NotFound, null).Detail.ShouldBeNull(); + } + [Fact] public void Two_faults_are_equal_by_reason_and_detail() { From da60907d17025fe2632064714e1e6aa1ff83fc4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:09:08 +0200 Subject: [PATCH 092/147] fix: no string makes a text guard throw, or compose in quadratic time AsText() threw ArgumentException on U+FFFE, from string.Normalize, where the guards promise a malformed string is refused rather than thrown over. A sweep of every scalar value found it the only one: U+FFFE, the byte-order mark read in the wrong byte order, is now Hidden in both guards, as an unpaired surrogate already was, and the sweep is a test (0.3 s). Composition reorders a run of combining marks in time that grows with the square of the run: a letter and n marks of each of two classes took 0.25 s at n = 10,000 and 64 s at 160,000, and the guide runs AsText() before a length check. A run of more than 30 marks (Mn, Mc, Me), the Stream-Safe Text Format's bound (UAX #15), is Hidden in both guards, counted in the linear scan before anything is composed; the adversarial shape at n = 100,000 answers at once (25 s, and a success, without the bound). Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit/StringTextExtensions.cs | 45 +++++++-- .../StringTextExtensionsTests.cs | 94 +++++++++++++++++-- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/src/CodoMetis.TypeKit/StringTextExtensions.cs b/src/CodoMetis.TypeKit/StringTextExtensions.cs index aa89c8a..414525b 100644 --- a/src/CodoMetis.TypeKit/StringTextExtensions.cs +++ b/src/CodoMetis.TypeKit/StringTextExtensions.cs @@ -20,19 +20,33 @@ namespace CodoMetis.TypeKit; /// category test cannot see a character above the Basic Multilingual Plane, and reports /// where the real category is , /// which is how the tag characters used to hide text inside text pass such a test. An unpaired surrogate -/// is not a character: throws on it, so it is refused as -/// rather than thrown over. +/// is not a character, and U+FFFE, the byte-order mark read in the wrong byte order, is the one +/// noncharacter refuses: it throws on both, so both are refused as +/// rather than thrown over, by both guards, so that no string makes either +/// throw. +/// +/// +/// Both refuse a run of more than 30 combining marks in a row as : no +/// script stacks that many on one character, and composing such a run reorders it in time that grows with +/// the square of its length, so one request body of stacked marks could hold a core for a minute. Thirty +/// is the bound of the Stream-Safe Text Format of Unicode Standard Annex #15. The scan that counts them +/// is linear, and stops at the 31st. /// /// /// Not refused: unassigned code points, since the runtime's Unicode tables lag the standard and a -/// name spelled with a newly assigned character would be refused until the SDK caught up; and +/// name spelled with a newly assigned character would be refused until the SDK caught up; the other +/// noncharacters (U+FFFF, U+FDD0, U+1FFFE), which no runtime call refuses; and /// confusable letters across scripts, which is a comparison question and not a rejection. /// /// public static class StringTextExtensions { - private const int ZeroWidthNonJoiner = 0x200C; - private const int ZeroWidthJoiner = 0x200D; + private const int ZeroWidthNonJoiner = 0x200C; + private const int ZeroWidthJoiner = 0x200D; + private const int SwappedByteOrderMark = 0xFFFE; + + /// The longest run of combining marks kept: the Stream-Safe Text Format's bound (UAX #15). + private const int LongestRunOfMarks = 30; /// The string, which may be null: a null is . extension(string? text) @@ -121,8 +135,9 @@ private static string FoldSpaces(string text) } /// - /// Whether the text carries a character that renders as nothing, or renders no character of its - /// own at all. + /// Whether the text carries a character that renders as nothing, a code point composition throws + /// on, or a run of marks too long to compose in linear time, or renders no character of its own at + /// all. /// /// /// @@ -139,14 +154,19 @@ private static bool IsHidden(string text, bool tolerateJoiners) var rest = text.AsSpan(); var afterBase = false; var rendersBase = false; + var marksInARow = 0; while (!rest.IsEmpty) { - if (Rune.DecodeFromUtf16(rest, out var rune, out var consumed) != OperationStatus.Done) + // An unpaired surrogate and U+FFFE are what string.Normalize throws on. + if (Rune.DecodeFromUtf16(rest, out var rune, out var consumed) != OperationStatus.Done || rune.Value == SwappedByteOrderMark) return true; if (!IsRendered(rune, tolerateJoiners)) return true; + marksInARow = IsCombiningMark(rune) ? marksInARow + 1 : 0; + if (marksInARow > LongestRunOfMarks) return true; + if (IsVariationSelector(rune)) { if (!afterBase) return true; @@ -171,7 +191,14 @@ private static bool IsHidden(string text, bool tolerateJoiners) private static bool IsBase(Rune rune) => rune.Value is not (ZeroWidthNonJoiner or ZeroWidthJoiner) && !Rune.IsWhiteSpace(rune) - && Rune.GetUnicodeCategory(rune) is not (UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark); + && !IsCombiningMark(rune); + + /// + /// A mark drawn on another character. Every character that composition may reorder is one, so a + /// bound on a run of these bounds the reordering. + /// + private static bool IsCombiningMark(Rune rune) => + Rune.GetUnicodeCategory(rune) is UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark; /// /// The variation selectors, which Unicode files as non-spacing marks: the sixteen of the BMP, the diff --git a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs index 7cd45d6..322df03 100644 --- a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs +++ b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace CodoMetis.TypeKit.Tests; /// @@ -6,11 +8,11 @@ namespace CodoMetis.TypeKit.Tests; /// /// /// The character table is the point of this file. Every entry is a code point that walks through -/// IsNullOrWhiteSpace and survives Trim, and between them they are refused for four -/// different structural reasons: category Control, category Format, a category that -/// claims "letter" or "symbol" while the glyph is empty, and a plane above the BMP. Pinning them by -/// value rather than by category means a runtime that reclassifies one fails here instead of -/// quietly reopening the defect. +/// IsNullOrWhiteSpace and survives Trim inside a value, and between them they are refused +/// for five different structural reasons: category Control, category Format, a category +/// that claims "letter", "mark" or "symbol" while the glyph is empty, a plane above the BMP, and a code +/// point string.Normalize throws on. Pinning them by value rather than by category means a +/// runtime that reclassifies one fails here instead of quietly reopening the defect. /// /// /// Every entry is written as an escape and named in the second column, because the subject of this @@ -41,6 +43,7 @@ public sealed class StringTextExtensionsTests { "\u3164", "HANGUL FILLER" }, { "\uFEFF", "ZERO WIDTH NO-BREAK SPACE" }, { "\uFFA0", "HALFWIDTH HANGUL FILLER" }, + { "\uFFFE", "NONCHARACTER FFFE, a byte-order mark read in the wrong byte order" }, { "\U0001D173", "MUSICAL SYMBOL BEGIN BEAM" }, { "\U000E0001", "LANGUAGE TAG" }, { "\U000E0041", "TAG LATIN CAPITAL A" }, @@ -235,6 +238,82 @@ public void An_unpaired_surrogate_is_refused_rather_than_thrown_over() Refusal("Muster\uD834mann".AsIdentifier()).ShouldBe(TextFault.Hidden); } + /// + /// string.Normalize throws on two things, an unpaired surrogate and U+FFFE, and + /// AsText() threw on the second. Every scalar value after a letter, through both guards: + /// nothing a client can send makes either throw. + /// + [Fact] + public void No_scalar_value_makes_either_guard_throw() + { + var swept = 0; + var thrown = new List(); + + for (var value = 0; value <= 0x10FFFF; value++) + { + if (!Rune.IsValid(value)) continue; + + var text = string.Concat("a", char.ConvertFromUtf32(value)); + swept++; + + try + { + _ = text.AsText(); + _ = text.AsIdentifier(); + } + catch (Exception exception) + { + thrown.Add($"U+{value:X4}: {exception.GetType().Name}"); + } + } + + swept.ShouldBe(0x110000 - 0x800, "every scalar value: all code points but the surrogates"); + thrown.ShouldBeEmpty(); + } + + /// + /// Composition reorders a run of combining marks by their classes in time that grows with the + /// square of the run: a letter and 160,000 marks of each of two classes took a minute. No script + /// stacks more than a few on one character, and UAX #15's Stream-Safe Text Format bounds a run at + /// 30, so a longer one is refused, by both guards, before anything is composed. + /// + [Fact] + public void More_than_30_combining_marks_in_a_row_are_hidden() + { + var thirty = "a" + string.Concat(Enumerable.Repeat("\u0301\u0316", 15)); + var thirtyOne = thirty + "\u0301"; + + Kept(thirty.AsText()).ShouldBe(thirty.Normalize(NormalizationForm.FormC)); + Kept(thirty.AsIdentifier()).ShouldBe(thirty); + Refusal(thirtyOne.AsText()).ShouldBe(TextFault.Hidden); + Refusal(thirtyOne.AsIdentifier()).ShouldBe(TextFault.Hidden); + } + + /// A run is of marks in a row: a base character between two runs of 30 ends the first. + [Fact] + public void Runs_of_marks_on_separate_characters_are_counted_apart() + { + var marks = string.Concat(Enumerable.Repeat("\u0301\u0316", 15)); + var two = $"a{marks}b{marks}"; + + Kept(two.AsText()).ShouldBe(two.Normalize(NormalizationForm.FormC)); + Kept(two.AsIdentifier()).ShouldBe(two); + } + + /// + /// The shape that pinned a core: a letter, then marks of one class, then marks of another, which + /// composition reorders. Refused at the 31st mark, it answers at once; composed, it answered a + /// success after 25 seconds. + /// + [Fact] + public void A_long_stack_of_marks_is_refused_rather_than_composed() + { + var stacked = "a" + new string('\u0301', 100_000) + new string('\u0316', 100_000); + + Refusal(stacked.AsText()).ShouldBe(TextFault.Hidden); + Refusal(stacked.AsIdentifier()).ShouldBe(TextFault.Hidden); + } + [Fact] public void Surrounding_whitespace_is_not_content() { @@ -272,5 +351,8 @@ private static string Kept(Result result) => result.Match(text => text, fault => throw new ShouldAssertException($"Refused as {fault}.")); private static TextFault Refusal(Result result) => - result.Match(text => throw new ShouldAssertException($"Kept \"{text}\"."), fault => fault); + result.Match(text => throw new ShouldAssertException($"Kept \"{Shown(text)}\"."), fault => fault); + + /// The text a failure prints, cut short: a refusal that failed prints the value, which can be 200,000 marks. + private static string Shown(string text) => text.Length <= 80 ? text : $"{text[..80]}... ({text.Length} chars)"; } From 0e450c997c8d49bb181627bfa48ec49ad541a129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:09:49 +0200 Subject: [PATCH 093/147] fix: the text guards refuse the Khmer inherent vowels and the Khitan filler, and keep the prepended concatenation marks "admin" + U+17B4 passed both guards, as did U+17B5 and U+16FE4: the Khmer inherent vowels and the Khitan small script filler render nothing and are default-ignorable, but Unicode files them as non-spacing marks, so no category test reached them. They join the characters that render nothing whatever their category. The other way round, the prepended concatenation marks (U+0600..U+0605, U+06DD, U+070F, U+0890, U+0891, U+08E2, U+110BD, U+110CD, the set of the Unicode 16 tables the runtime follows) are format characters by category, yet each is drawn, spanning the digits after it: refusing them refused the Arabic number sign and the end of ayah, Arabic text the guard says it keeps. Both guards keep them, also with nothing after them, since one alone draws itself. Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit/StringTextExtensions.cs | 35 ++++++++++++--- .../StringTextExtensionsTests.cs | 45 ++++++++++++++++++- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/CodoMetis.TypeKit/StringTextExtensions.cs b/src/CodoMetis.TypeKit/StringTextExtensions.cs index 414525b..d3acfb2 100644 --- a/src/CodoMetis.TypeKit/StringTextExtensions.cs +++ b/src/CodoMetis.TypeKit/StringTextExtensions.cs @@ -35,7 +35,9 @@ namespace CodoMetis.TypeKit; /// /// Not refused: unassigned code points, since the runtime's Unicode tables lag the standard and a /// name spelled with a newly assigned character would be refused until the SDK caught up; the other -/// noncharacters (U+FFFF, U+FDD0, U+1FFFE), which no runtime call refuses; and +/// noncharacters (U+FFFF, U+FDD0, U+1FFFE), which no runtime call refuses; the +/// prepended concatenation marks, which Unicode files as format characters although each is drawn, such +/// as the Arabic number sign, including one with no digit after it, since it then draws itself; and /// confusable letters across scripts, which is a comparison question and not a rejection. /// /// @@ -212,6 +214,7 @@ private static bool IsVariationSelector(Rune rune) => private static bool IsRendered(Rune rune, bool tolerateJoiners) => (tolerateJoiners && rune.Value is ZeroWidthNonJoiner or ZeroWidthJoiner) + || IsPrependedConcatenationMark(rune) || (Rune.GetUnicodeCategory(rune) switch { // Control covers NUL and the C0/C1 range; Format covers the zero-width spaces, the @@ -232,10 +235,28 @@ or UnicodeCategory.LineSeparator /// nevertheless files as letters, marks or symbols, so no category test reaches them. /// private static bool IsInvisibleDespiteItsCategory(Rune rune) => - rune.Value is 0x034F // COMBINING GRAPHEME JOINER (NonSpacingMark) - or 0x115F // HANGUL CHOSEONG FILLER (OtherLetter) - or 0x1160 // HANGUL JUNGSEONG FILLER (OtherLetter) - or 0x2800 // BRAILLE PATTERN BLANK (OtherSymbol) - or 0x3164 // HANGUL FILLER (OtherLetter) - or 0xFFA0; // HALFWIDTH HANGUL FILLER (OtherLetter) + rune.Value is 0x034F // COMBINING GRAPHEME JOINER (NonSpacingMark) + or 0x115F // HANGUL CHOSEONG FILLER (OtherLetter) + or 0x1160 // HANGUL JUNGSEONG FILLER (OtherLetter) + or 0x17B4 // KHMER VOWEL INHERENT AQ (NonSpacingMark) + or 0x17B5 // KHMER VOWEL INHERENT AA (NonSpacingMark) + or 0x2800 // BRAILLE PATTERN BLANK (OtherSymbol) + or 0x3164 // HANGUL FILLER (OtherLetter) + or 0xFFA0 // HALFWIDTH HANGUL FILLER (OtherLetter) + or 0x16FE4; // KHITAN SMALL SCRIPT FILLER (NonSpacingMark) + + /// + /// The prepended concatenation marks, which Unicode files as format characters although each is + /// drawn: a sign that spans the digits or letters after it. The set of Unicode 16, whose tables the + /// runtime follows. One with nothing after it draws itself, so it is a character of its own. + /// + private static bool IsPrependedConcatenationMark(Rune rune) => + rune.Value is >= 0x0600 and <= 0x0605 // ARABIC NUMBER SIGN .. ARABIC NUMBER MARK ABOVE + or 0x06DD // ARABIC END OF AYAH + or 0x070F // SYRIAC ABBREVIATION MARK + or 0x0890 // ARABIC POUND MARK ABOVE + or 0x0891 // ARABIC PIASTRE MARK ABOVE + or 0x08E2 // ARABIC DISPUTED END OF AYAH + or 0x110BD // KAITHI NUMBER SIGN + or 0x110CD; // KAITHI NUMBER SIGN ABOVE } diff --git a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs index 322df03..1993f60 100644 --- a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs +++ b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs @@ -16,10 +16,10 @@ namespace CodoMetis.TypeKit.Tests; /// /// /// Every entry is written as an escape and named in the second column, because the subject of this -/// file is characters a reader cannot see. The five above U+FFFF are the ones a plausible +/// file is characters a reader cannot see. The six above U+FFFF are the ones a plausible /// implementation gets wrong: CharUnicodeInfo.GetUnicodeCategory over a /// answers Surrogate for either half of a pair and never the category of the character the -/// pair encodes, so a per-char scan for Format accepts all five. +/// pair encodes, so a per-char scan accepts all six. /// /// public sealed class StringTextExtensionsTests @@ -32,6 +32,8 @@ public sealed class StringTextExtensionsTests { "\u034F", "COMBINING GRAPHEME JOINER" }, { "\u115F", "HANGUL CHOSEONG FILLER" }, { "\u1160", "HANGUL JUNGSEONG FILLER" }, + { "\u17B4", "KHMER VOWEL INHERENT AQ" }, + { "\u17B5", "KHMER VOWEL INHERENT AA" }, { "\u180E", "MONGOLIAN VOWEL SEPARATOR" }, { "\u200B", "ZERO WIDTH SPACE" }, { "\u200E", "LEFT-TO-RIGHT MARK" }, @@ -44,6 +46,7 @@ public sealed class StringTextExtensionsTests { "\uFEFF", "ZERO WIDTH NO-BREAK SPACE" }, { "\uFFA0", "HALFWIDTH HANGUL FILLER" }, { "\uFFFE", "NONCHARACTER FFFE, a byte-order mark read in the wrong byte order" }, + { "\U00016FE4", "KHITAN SMALL SCRIPT FILLER" }, { "\U0001D173", "MUSICAL SYMBOL BEGIN BEAM" }, { "\U000E0001", "LANGUAGE TAG" }, { "\U000E0041", "TAG LATIN CAPITAL A" }, @@ -62,6 +65,28 @@ public sealed class StringTextExtensionsTests { "\u200D", "ZERO WIDTH JOINER" } }; + /// + /// Unicode's prepended concatenation marks, of Unicode 16, whose tables the runtime follows: format + /// characters by category, yet each is drawn, as a sign that spans the digits or letters after it. + /// Refused as hidden, they refused Arabic and Syriac text the guard says it keeps. + /// + public static TheoryData PrependedConcatenationMarks => new() + { + { "\u0600", "ARABIC NUMBER SIGN" }, + { "\u0601", "ARABIC SIGN SANAH" }, + { "\u0602", "ARABIC FOOTNOTE MARKER" }, + { "\u0603", "ARABIC SIGN SAFHA" }, + { "\u0604", "ARABIC SIGN SAMVAT" }, + { "\u0605", "ARABIC NUMBER MARK ABOVE" }, + { "\u06DD", "ARABIC END OF AYAH" }, + { "\u070F", "SYRIAC ABBREVIATION MARK" }, + { "\u0890", "ARABIC POUND MARK ABOVE" }, + { "\u0891", "ARABIC PIASTRE MARK ABOVE" }, + { "\u08E2", "ARABIC DISPUTED END OF AYAH" }, + { "\U000110BD", "KAITHI NUMBER SIGN" }, + { "\U000110CD", "KAITHI NUMBER SIGN ABOVE" } + }; + /// /// Every entry renders exactly as an ordinary space and, before folding, made two values out of /// one name. Confusable letters across scripts are deliberately not folded. @@ -85,6 +110,22 @@ public void A_character_that_renders_as_nothing_is_refused_as_text(string charac public void A_character_that_renders_as_nothing_is_refused_as_an_identifier(string character, string name) => Refusal($"subject{character}id".AsIdentifier()).ShouldBe(TextFault.Hidden, name); + /// + /// The mark spans the digits after it, here the Arabic-Indic one, two and three; with nothing after + /// it, it draws itself, so a value of the mark alone is a character of its own and is kept too. + /// + [Theory] + [MemberData(nameof(PrependedConcatenationMarks))] + public void A_prepended_concatenation_mark_is_drawn_and_kept(string mark, string name) + { + var numbered = $"{mark}\u0661\u0662\u0663"; + + Kept(numbered.AsText()).ShouldBe(numbered, name); + Kept(numbered.AsIdentifier()).ShouldBe(numbered, name); + Kept(mark.AsText()).ShouldBe(mark, $"{name}, with nothing after it"); + Kept(mark.AsIdentifier()).ShouldBe(mark, $"{name}, with nothing after it"); + } + [Theory] [MemberData(nameof(Joiners))] public void A_joiner_is_orthography_in_text_and_noise_in_an_identifier(string character, string name) From a6da5e35d24fb8b15f9ccc932b8583dedb1cbbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:10:51 +0200 Subject: [PATCH 094/147] docs: the text guards name everything they refuse, and that the form is NFC The base README, the XML documentation and TextFault.Hidden said "a character that renders as nothing", while the guards also refuse a tab, a line feed, a carriage return, the soft hyphen, the byte-order mark, the bidirectional marks (an Arabic name ending in a right-to-left mark is Hidden), private use, U+FFFE and runs of marks. TextFault.Hidden now lists the refusals exactly, the README's section lists them by category, docs/text.md gives each a row, and a multi-line field such as a comment is said not to go through AsText(), which docs/text.md listed as an AsText() use. The form is C, not KC: a ligature, a superscript or a fullwidth letter is kept as written. No test used a compatibility character, so NFKC passed the suite; one does now, and the table pins line feed, carriage return, CRLF, the soft hyphen and the right-to-left mark as Hidden. Co-Authored-By: Claude Opus 5.5 --- docs/text.md | 28 +++++++++----- src/CodoMetis.TypeKit/README.md | 38 ++++++++++++++----- src/CodoMetis.TypeKit/StringTextExtensions.cs | 33 ++++++++++++---- src/CodoMetis.TypeKit/TextFault.cs | 24 +++++++++++- .../StringTextExtensionsTests.cs | 17 +++++++++ 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/docs/text.md b/docs/text.md index 7a4869c..3862197 100644 --- a/docs/text.md +++ b/docs/text.md @@ -4,24 +4,29 @@ Every application that stores what a person typed trims it and refuses a blank. zero-width space, a Hangul filler or NUL, which `IsNullOrWhiteSpace` lets through and `Trim` leaves in place. Fewer compose it, so a name written with `ü` and the same name written with `u` and a combining diaeresis are two rows of a unique index that render alike. `AsText()` does all of this for -text a person wrote, and `AsIdentifier()` trims and refuses for a value another system issued; both -are extensions on `string` in `CodoMetis.TypeKit` and answer with a `Result`. The -[base README](../src/CodoMetis.TypeKit/README.md#written-text) is their reference; this page is about -using them: which one, where, and inside a value object. The examples import `CodoMetis.TypeKit`. +a line of text a person wrote, and `AsIdentifier()` trims and refuses for a value another system +issued; both are extensions on `string` in `CodoMetis.TypeKit` and answer with a +`Result`. The [base README](../src/CodoMetis.TypeKit/README.md#written-text) is +their reference and lists exactly what they refuse; this page is about using them: which one, where, +and inside a value object. The examples import `CodoMetis.TypeKit`. ## Two guards | | `AsText()` | `AsIdentifier()` | |---|---|---| -| For | text a person wrote: a name, a description, a comment | a value another system issued: a subject claim, a session id, a key | +| For | one line of text a person wrote: a name, a title, a subject line | a value another system issued: a subject claim, a session id, a key | | Trims | yes | yes | | Folds every space separator to an ordinary space | yes | no | -| Composes to NFC, so one spelling per name | yes | no, since an issued value is compared against what its issuer sends next time | +| Composes to NFC, so one spelling per name | yes, and only that: a ligature, a superscript or a fullwidth letter stays as written, as NFKC would not keep it | no, since an issued value is compared against what its issuer sends next time | | Refuses a character that renders as nothing | yes | yes | -| A line break, a tab or another control character | refused as `Hidden`: a value is one line, so a multi-line comment is split into lines first, or stays the `string` it is | refused | +| A line break, a tab or another control character | refused as `Hidden`: a value is one line, so a multi-line field such as a comment does not go through the guard | refused | +| A format character: a soft hyphen, a byte-order mark, a bidirectional mark or override, a tag character | refused, so an Arabic name ending in a right-to-left mark is refused too | refused | +| The Arabic number sign, the end of ayah and the other prepended concatenation marks | kept: format characters by category, but drawn, spanning the digits after them | kept | | The two zero-width joiners | kept: they spell Persian, Arabic and Indic scripts and hold an emoji sequence together | refused: no issuer needs one | | A variation selector | kept after the character it varies (an emoji's presentation, an ideograph's variant); elsewhere it selects nothing and is refused | the same | | A value of joiners, variation selectors and combining marks alone | refused: it renders no character of its own | refused | +| More than 30 combining marks in a row | refused: no script stacks that many, and composing a run takes time that grows with the square of its length | refused | +| An unpaired surrogate, or `U+FFFE` | refused, where `Normalize` would throw | refused | | A null receiver | `Blank` | `Blank` | ```csharp @@ -37,9 +42,10 @@ Result forged = "sub\u200Dject".AsIdentifier(); // Error Both scan by `Rune`, so a character above the Basic Multilingual Plane is seen for what it is, and an unpaired surrogate is `Hidden` rather than an exception, which `Normalize` and a database driver would -both throw on. Not refused on purpose: unassigned code points, since the runtime's Unicode tables lag -the standard and a name spelled with a newly assigned character would be refused until the SDK caught -up; and confusable letters across scripts, which is a comparison question and not a rejection. +both throw on. No string makes either guard throw, and none makes either take more than linear time. +Not refused on purpose: unassigned code points, since the runtime's Unicode tables lag the standard +and a name spelled with a newly assigned character would be refused until the SDK caught up; and +confusable letters across scripts, which is a comparison question and not a rejection. ## Required and optional @@ -105,6 +111,8 @@ as `SubjectId` shows. - **A password or a secret.** Composing or folding it changes what the person typed; compare it as given, and never through a guard that would also put it in a fault. +- **A multi-line field**, such as a comment or a message body: a line break is a control character, + refused as `Hidden`. Guard each line, or keep the field as the `string` it is. - **Text another system will parse**, such as a path or a URL: it has its own rules, and trimming it is already a change. - **A comparison key.** Confusable letters across scripts and case folding are a comparison's diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 344da93..1eb426a 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -360,17 +360,35 @@ var subject = "subject\u200Bid".AsIdentifier(); // Error: Hidden, a zero-width var missing = ((string?)null).AsText(); // Error: Blank ``` -Which one to call is decided by who chose the value. `AsText()` is for text a person wrote: it -trims, folds every space separator to an ordinary space, composes to NFC and refuses any character -that renders as nothing, keeping the two zero-width joiners because they spell Persian, Arabic and -Indic scripts and hold an emoji sequence together. `AsIdentifier()` is for a value another system +Which one to call is decided by who chose the value. `AsText()` is for one line of text a person +wrote, a name, a title: it trims, folds every space separator to an ordinary space, composes to NFC +(not NFKC, so a ligature or a fullwidth letter stays as written) and refuses a character that renders +as nothing or breaks the line, keeping the two zero-width joiners because they spell Persian, Arabic +and Indic scripts and hold an emoji sequence together. `AsIdentifier()` is for a value another system issued, a subject claim, a session id, a key: it trims and alters nothing else, since an issued -value is compared against what its issuer sends next time, and it refuses the joiners too. A -variation selector is kept after the character it varies and refused anywhere else, and a value of -joiners, selectors and combining marks alone renders no character of its own and is refused by -both. Both -scan by `Rune`, so a character above the BMP is seen for what it is, and an unpaired surrogate is -`Hidden` rather than an exception. +value is compared against what its issuer sends next time, and it refuses the joiners too. + +Both refuse as `Hidden`, once the surrounding whitespace is trimmed: + +- a control character: NUL, a tab, a line feed, a carriage return, the rest of the C0 and C1 ranges. + A multi-line field, such as a comment, does not go through either guard; +- a format character: the soft hyphen, the zero-width space, the word joiner, the byte-order mark, the + bidirectional marks, embeddings, overrides and isolates (so an Arabic name ending in a right-to-left + mark is refused), the invisible operators and the tag characters. Not the Arabic number sign, the end + of ayah and the other prepended concatenation marks, which are drawn, even with nothing after them; +- a private-use character, and the line and paragraph separators; +- an unpaired surrogate, and `U+FFFE`, a byte-order mark read in the wrong byte order: what + `string.Normalize` throws on, refused rather than thrown over; +- a character that renders nothing whatever its category: the combining grapheme joiner, the Hangul + fillers, the Braille pattern blank, the Khmer inherent vowels and the Khitan filler; +- a variation selector anywhere but after the character it varies (an emoji's presentation, an + ideograph's variant); +- more than 30 combining marks in a row: no script stacks that many, and composing a run reorders it in + time that grows with the square of its length; +- a value of joiners, variation selectors and combining marks alone, which renders no character of + its own. + +Both scan by `Rune`, so a character above the BMP is seen for what it is. `TextFault` has two members because the caller decides what a blank means: a required field makes `Blank` an error, an optional one makes it absence. `Hidden` is a refusal in both. A null is diff --git a/src/CodoMetis.TypeKit/StringTextExtensions.cs b/src/CodoMetis.TypeKit/StringTextExtensions.cs index d3acfb2..b9dd56a 100644 --- a/src/CodoMetis.TypeKit/StringTextExtensions.cs +++ b/src/CodoMetis.TypeKit/StringTextExtensions.cs @@ -54,16 +54,24 @@ public static class StringTextExtensions extension(string? text) { /// - /// The string as text a person wrote, as it will be kept: trimmed, every space separator folded - /// to an ordinary space, composed to Unicode normalization form C, and refused if it carries a - /// character that renders as nothing. + /// The string as one line of text a person wrote, as it will be kept: trimmed, every space + /// separator folded to an ordinary space, composed to Unicode normalization form C, and refused if + /// it carries a character that renders as nothing or breaks the line. /// /// /// /// Neither canonicalisation is a convenience. Two canonically equivalent spellings of one name, /// or one name spaced with a no-break space, compare unequal under every ordinal comparison and /// under a unique index a database compares byte by byte, while rendering identically on every - /// screen. + /// screen. The form is C, not KC: a ligature, a superscript or a fullwidth letter is kept as written. + /// + /// + /// Refused as , which lists them exactly: a control character, a tab + /// and a line break among them, so a multi-line field such as a comment does not go through this + /// guard; a format character, a soft hyphen, the byte-order mark and the bidirectional marks among + /// them; a private-use character; a line or paragraph separator; a character that renders nothing + /// whatever its category; a variation selector that varies nothing; more than 30 combining marks in + /// a row; and a value of no character of its own. /// /// /// The two zero-width joiners are kept: they are ordinary orthography in Persian, Arabic and @@ -72,24 +80,35 @@ public static class StringTextExtensions /// /// /// The text, or for a null, empty or whitespace-only string, or - /// for one carrying a character that renders as nothing. + /// for one carrying a character that renders as nothing or breaks + /// the line. /// public Result AsText() => Clean(text, tolerateJoiners: true, canonicalize: true); /// /// The string as a value another system issued, a subject claim, a session id, a key: trimmed of /// surrounding whitespace, otherwise exactly as it arrived, and refused if it carries a - /// character that renders as nothing. + /// character that renders as nothing or breaks the line. /// /// + /// /// Nothing is composed or folded, since an issued value is compared against what its issuer /// sends next time, and canonicalising it alters an attested value. Trimming survives because /// surrounding whitespace is a transport artefact. The zero-width joiners /// keeps are refused here, since no issuer needs one. + /// + /// + /// Refused as , which lists them exactly, as + /// refuses them, and the two zero-width joiners besides: a control character, a tab and a line + /// break among them; a format character; a private-use character; a line or paragraph separator; + /// a character that renders nothing whatever its category; a variation selector that varies + /// nothing; more than 30 combining marks in a row; and a value of no character of its own. + /// /// /// /// The value, or for a null, empty or whitespace-only string, or - /// for one carrying a character that renders as nothing. + /// for one carrying a character that renders as nothing or breaks + /// the line. /// public Result AsIdentifier() => Clean(text, tolerateJoiners: false, canonicalize: false); } diff --git a/src/CodoMetis.TypeKit/TextFault.cs b/src/CodoMetis.TypeKit/TextFault.cs index 4099653..0243b93 100644 --- a/src/CodoMetis.TypeKit/TextFault.cs +++ b/src/CodoMetis.TypeKit/TextFault.cs @@ -4,13 +4,33 @@ namespace CodoMetis.TypeKit; /// /// Two faults rather than one because the caller decides what means: a required /// field reads it as an error, an optional one as absence. is a refusal in both, -/// since nothing asks for a character that renders as nothing. +/// since nothing asks for a character that renders as nothing, and a value of one line asks for no line +/// break. /// public enum TextFault { /// Null, empty, or nothing but whitespace. Blank, - /// Carries at least one character that renders as nothing. + /// + /// Carries at least one character that renders as nothing, that changes how the text around it is + /// shown, or that ends the line; or renders no character of its own. + /// + /// + /// Exactly, with the surrounding whitespace trimmed first, a value that carries: + /// + /// a control character (category Cc): NUL, a tab, a line feed, a carriage return, the rest of the C0 range, DEL and the C1 range; + /// a format character (Cf): the soft hyphen, the zero-width space, the word joiner, the byte-order mark, the bidirectional marks, embeddings, overrides and isolates, the invisible operators, the tag characters; but not a prepended concatenation mark, such as the Arabic number sign, which is drawn, nor, as AsText() reads it, the zero-width joiner and non-joiner; + /// a private-use character (Co), or the line or paragraph separator (Zl, Zp); + /// an unpaired surrogate, or U+FFFE, the byte-order mark in the wrong byte order: what string.Normalize throws on; + /// a character that renders nothing whatever its category: the combining grapheme joiner, the Hangul fillers, the Braille pattern blank, the Khmer inherent vowels and the Khitan filler; + /// a variation selector that follows no character it could vary; + /// more than 30 combining marks in a row, the Stream-Safe Text Format's bound; + /// + /// + /// or a value of joiners, variation selectors and combining marks alone. Unassigned code points and + /// confusable letters are not refused. + /// + /// Hidden } diff --git a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs index 1993f60..f7c0e0a 100644 --- a/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs +++ b/test/CodoMetis.TypeKit.Tests/StringTextExtensionsTests.cs @@ -29,6 +29,10 @@ public sealed class StringTextExtensionsTests { { "\u0000", "NUL" }, { "\u0009", "TAB" }, + { "\u000A", "LINE FEED" }, + { "\u000D", "CARRIAGE RETURN" }, + { "\u000D\u000A", "CARRIAGE RETURN, LINE FEED" }, + { "\u00AD", "SOFT HYPHEN" }, { "\u034F", "COMBINING GRAPHEME JOINER" }, { "\u115F", "HANGUL CHOSEONG FILLER" }, { "\u1160", "HANGUL JUNGSEONG FILLER" }, @@ -37,6 +41,7 @@ public sealed class StringTextExtensionsTests { "\u180E", "MONGOLIAN VOWEL SEPARATOR" }, { "\u200B", "ZERO WIDTH SPACE" }, { "\u200E", "LEFT-TO-RIGHT MARK" }, + { "\u200F", "RIGHT-TO-LEFT MARK" }, { "\u202E", "RIGHT-TO-LEFT OVERRIDE" }, { "\u2028", "LINE SEPARATOR" }, { "\u2029", "PARAGRAPH SEPARATOR" }, @@ -355,6 +360,18 @@ public void A_long_stack_of_marks_is_refused_rather_than_composed() Refusal(stacked.AsIdentifier()).ShouldBe(TextFault.Hidden); } + /// + /// The form is C, not KC: compatibility characters are what the person wrote, and folding a + /// ligature, a superscript or a fullwidth letter to its plain form changes the text, where + /// composing changes only its encoding. + /// + [Theory] + [InlineData("\uFB01le", "LATIN SMALL LIGATURE FI")] + [InlineData("x\u00B2", "SUPERSCRIPT TWO")] + [InlineData("\uFF21\uFF22", "FULLWIDTH LATIN CAPITAL LETTER A, B")] + public void A_compatibility_character_is_kept_as_written(string value, string name) => + Kept(value.AsText()).ShouldBe(value, name); + [Fact] public void Surrounding_whitespace_is_not_content() { From ddcb5496c4cae7955ad47d03e6fa5bb23d6a8589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:11:42 +0200 Subject: [PATCH 095/147] fix: AsText() throws in globalization-invariant mode rather than keep text uncomposed With DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, or InvariantGlobalization in the project, which the webapiaot template sets, string.Normalize returns its input unchanged, and AsText() answered "Zoe" with a combining diaeresis as a success, uncomposed, and kept U+212B as it came: one name, two spellings, silently. The environment variable leaves the System.Globalization.Invariant switch unset, so AsText() asks once whether a decomposed letter composes, and throws PlatformNotSupportedException where it does not, whatever the string, so the first call finds out. The message names the mode and the way out, the mode off and ICU available, both, and ends with a link to a new "Why does this throw?" subsection. Written text has no guard in that mode; AsIdentifier() composes nothing and works there, for issued values only. InvariantGlobalizationTests runs the test assembly again in a child process with the variable set, filtered to itself, and checks the refusal there. Decision 59 records this and the two refusals before it. Co-Authored-By: Claude Opus 5.5 --- ...-guard-refuses-what-it-cannot-normalize.md | 42 ++++++++++ design/adr/README.md | 1 + design/native-aot.md | 6 ++ docs/native-aot.md | 12 +++ docs/text.md | 16 ++++ src/CodoMetis.TypeKit/README.md | 23 ++++- src/CodoMetis.TypeKit/Refusals.cs | 3 + src/CodoMetis.TypeKit/StringTextExtensions.cs | 52 +++++++++++- .../InvariantGlobalizationTests.cs | 84 +++++++++++++++++++ .../RefusalLinkTests.cs | 21 ++++- 10 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 design/adr/0059-text-guard-refuses-what-it-cannot-normalize.md create mode 100644 test/CodoMetis.TypeKit.Tests/InvariantGlobalizationTests.cs diff --git a/design/adr/0059-text-guard-refuses-what-it-cannot-normalize.md b/design/adr/0059-text-guard-refuses-what-it-cannot-normalize.md new file mode 100644 index 0000000..66d46d0 --- /dev/null +++ b/design/adr/0059-text-guard-refuses-what-it-cannot-normalize.md @@ -0,0 +1,42 @@ +# 0059. The text guards refuse what they cannot normalize, and `AsText()` throws where nothing composes + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; refines +[decision 43](0043-exceptions-type-argument-constraints-constructor-guarantee.md), which shipped the +text guards. Measured on SDK 10.0.401, runtime 10.0.12 (Unicode 16 tables), osx-arm64 with ICU. + +The guards promise that no string makes them throw and that a value is kept in one spelling. Three +inputs broke that, and each is now refused or refused to run, never answered as if nothing happened: + +- **`U+FFFE`.** `string.Normalize` throws on an unpaired surrogate and on `U+FFFE`, the byte-order + mark read in the wrong byte order, and on nothing else: a sweep of every scalar value through + `AsText()` found `U+FFFE` alone (`U+FFFF`, `U+FDD0`, `U+1FFFE` pass). It is `Hidden` in both + guards, as an unpaired surrogate already was, and the sweep is a test. +- **A long run of combining marks.** Composition reorders a run of marks by their classes in time + that grows with the square of the run: a letter, n marks of one class and n of another took 0.25 s at + n = 10,000, 4.1 s at 40,000 and 64 s at 160,000, where ten million plain characters take 0.23 s, + and the guide runs `AsText()` before a length check. A run of more than 30 marks (Mn, Mc, Me) is + `Hidden` in both guards, counted in the linear scan before anything is composed. Thirty is the + Stream-Safe Text Format's bound (UAX #15). Every character of a non-zero combining class, and every + one whose canonical decomposition starts with one, is a mark (checked against Unicode 17's data), so + the bound bounds the reordering; counting all marks, not only those of a non-zero class, refuses a + little more and needs no class table the runtime does not expose. +- **Globalization-invariant mode.** There `string.Normalize` returns its input unchanged, and + `AsText()` answered `Zoe` with a combining diaeresis as a success, uncomposed, and kept `U+212B` + as it came: one name, two spellings, silently. `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT` does not set + the `System.Globalization.Invariant` switch, so `AsText()` asks once whether a decomposed letter + composes, and throws `PlatformNotSupportedException` where it does not, on its first call whatever + the string, its message naming the mode and the way out: the mode off and ICU available, both, since + ICU alone does nothing while the mode is on. Not a fallback to `AsIdentifier()`'s behaviour, which + is the silent answer itself, and not a `TextFault`, which describes a string and not the process. + `AsIdentifier()` composes nothing and is unaffected, but is no replacement: it refuses the joiners + written text needs and folds no space, so written text has no guard in the mode. + +Two corrections to what is refused rode along. The Khmer inherent vowels (`U+17B4`, `U+17B5`) and the +Khitan filler (`U+16FE4`) render nothing and are default-ignorable, but are marks, so no category test +reached them: they are `Hidden` now. The prepended concatenation marks (`U+0600` to `U+0605`, `U+06DD`, +`U+070F`, `U+0890`, `U+0891`, `U+08E2`, `U+110BD`, `U+110CD`) are format characters by category, yet +each is drawn, spanning the digits after it, so refusing them refused Arabic and Syriac text the guard +says it keeps: they are kept, also with nothing after them, since one alone draws itself. diff --git a/design/adr/README.md b/design/adr/README.md index 4fa8912..eec48c1 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -65,4 +65,5 @@ per area ([design](../plan.md)) link to the records they rest on. | [0056](0056-value-translated-only-through-typekit-converter.md) | `.Value` is translated only on a column TypeKit's converter stores | 2026-10-13 | accepted | | [0057](0057-hand-written-ordering-refused-in-query.md) | A query that orders by a hand-written `CompareTo` is refused | 2026-10-13 | accepted | | [0058](0058-integer-key-follows-ef-rule.md) | A value-object key is generated wherever EF generates its wrapped type's | 2026-10-13 | accepted | +| [0059](0059-text-guard-refuses-what-it-cannot-normalize.md) | The text guards refuse what they cannot normalize, and `AsText()` throws where nothing composes | 2026-10-13 | accepted | | [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | diff --git a/design/native-aot.md b/design/native-aot.md index faa24b1..78bdf5f 100644 --- a/design/native-aot.md +++ b/design/native-aot.md @@ -34,5 +34,11 @@ host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativ Such a value object on an entity is a `sealed partial record` instead; - EF 10's precompiled queries cast an entity to an internal interface, which does not compile for a **sealed** entity class. +- **Globalization-invariant mode** ([decision 59](adr/0059-text-guard-refuses-what-it-cannot-normalize.md)). + Native AOT templates set `InvariantGlobalization`, and there `string.Normalize` returns its input + unchanged (measured with `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`, which leaves the + `System.Globalization.Invariant` switch unset). `AsText()` asks once whether a decomposed letter + composes, and throws `PlatformNotSupportedException` where it does not; `InvariantGlobalizationTests` + runs the test assembly again in that mode. The smoke test's consumers do not call `AsText()`. - **Third-party packages** report their own warnings (NodaTime.Serialization.SystemTextJson 1.4.0, EF Core). The smoke test asserts only that none names a CodoMetis package or a woven value object. diff --git a/docs/native-aot.md b/docs/native-aot.md index 839c964..b50ed50 100644 --- a/docs/native-aot.md +++ b/docs/native-aot.md @@ -126,6 +126,18 @@ The reflection-based binder, outside Native AOT, binds a value object through it same: the reflection-based binder reads it through its type converter, the source generator does not, so bind its name as a `string` and `Parse` it. +## Written text + +`AsText()` composes to Unicode normalization form C through the runtime's globalization data, and a +Native AOT project often turns that data off with `true`, +as the `webapiaot` template does. In that mode `string.Normalize` returns its input unchanged, so +`AsText()` throws `PlatformNotSupportedException` on its first call, whatever the string, rather than +keep one name in two spellings. An application that calls `AsText()` sets `InvariantGlobalization` to +`false` and makes ICU available to the process (`icu-libs` on Alpine, `libicu` on Debian and Ubuntu); +ICU installed while the mode stays on does nothing. `AsIdentifier()` composes nothing and works in +either mode, but it is the guard for a value another system issued, not for written text +([Written text](text.md#globalization-invariant-mode)). + ## Serilog `Destructure.WithTypeKit()` works under Native AOT: a value object or a closed set is logged as what diff --git a/docs/text.md b/docs/text.md index 3862197..3a81d83 100644 --- a/docs/text.md +++ b/docs/text.md @@ -28,6 +28,7 @@ and inside a value object. The examples import `CodoMetis.TypeKit`. | More than 30 combining marks in a row | refused: no script stacks that many, and composing a run takes time that grows with the square of its length | refused | | An unpaired surrogate, or `U+FFFE` | refused, where `Normalize` would throw | refused | | A null receiver | `Blank` | `Blank` | +| In globalization-invariant mode | throws `PlatformNotSupportedException`, since it cannot compose there ([below](#globalization-invariant-mode)) | works, for issued values | ```csharp Result composed = "Zoe\u0308".AsText(); // Success: Zoë, composed to NFC: one code point for ë @@ -107,6 +108,21 @@ length rule, so a document or a request that carries a hidden character is refus before it reaches the domain. Where the guard is the whole rule, the fault type is `TextFault` itself, as `SubjectId` shows. +## Globalization-invariant mode + +`AsText()` composes through the runtime's globalization data. A process in globalization-invariant +mode has none: `true` in the project, which Native +AOT and container templates often set, or `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`. There +`string.Normalize` returns its input unchanged, and a guard that answered anyway would keep one name in +two spellings, without a word. So `AsText()` throws `PlatformNotSupportedException` there, on its first +call whatever the string, rather than on the first decomposed name a client happens to send. + +Written text has no guard in that mode. Where the application calls `AsText()`, turn the mode off and +make ICU available to the process (`libicu` on Debian and Ubuntu, `icu-libs` on Alpine); ICU installed +while the mode stays on does nothing. `AsIdentifier()` keeps working there, but only for what it is for, +a value another system issued: it refuses the joiners a Persian name or an emoji sequence needs and +folds no space, so it is no stand-in for `AsText()`. + ## Where it does not belong - **A password or a secret.** Composing or folding it changes what the person typed; compare it as diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 1eb426a..f37bdc0 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -388,7 +388,10 @@ Both refuse as `Hidden`, once the surrounding whitespace is trimmed: - a value of joiners, variation selectors and combining marks alone, which renders no character of its own. -Both scan by `Rune`, so a character above the BMP is seen for what it is. +Both scan by `Rune`, so a character above the BMP is seen for what it is. `AsText()` needs the +runtime's globalization data to compose, and in globalization-invariant mode it throws +([why](#astext-in-globalization-invariant-mode)), so written text has no guard there. +`AsIdentifier()` composes nothing and works there, for the values it is for. `TextFault` has two members because the caller decides what a blank means: a required field makes `Blank` an error, an optional one makes it absence. `Hidden` is a refusal in both. A null is @@ -672,6 +675,24 @@ spelled type argument chooses its overload itself. **Instead:** `CatchAsync`, which awaits the task: `await Result.CatchAsync(() => stream.ReadAsync(buffer).AsTask())`, or an `async` lambda. In `CatchAsync`, await the inner task in the delegate. +### AsText in globalization-invariant mode + +**Thrown:** `PlatformNotSupportedException: AsText() composes text to Unicode normalization form C, and +this process cannot…` from `AsText()`, whatever the string, in a process in globalization-invariant mode: +`true` in the project, which Native AOT and container +templates often set, or `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` in the environment. + +**Why:** in that mode `string.Normalize` returns its input unchanged, so `AsText()` would answer a name +written with a combining diaeresis as a success, uncomposed, and one name would be kept in two +spellings that compare unequal: the defect the guard exists to prevent, and silent. + +**Instead:** turn the mode off, `false` and no +`DOTNET_SYSTEM_GLOBALIZATION_INVARIANT`, and make ICU available to the process (`libicu` on Debian +and Ubuntu, `icu-libs` on Alpine). Both are needed: ICU installed while the mode stays on does +nothing. Written text has no guard in this mode. `AsIdentifier()` is no replacement: it is the guard +for a value another system issued, refuses the zero-width joiners that Persian, Arabic, Indic scripts +and emoji sequences need, and folds no space. + ### Materialize and CMTK0004 Not a run-time exception but a build error: `Materialize` rebuilds a value object without `Create`, diff --git a/src/CodoMetis.TypeKit/Refusals.cs b/src/CodoMetis.TypeKit/Refusals.cs index 713b310..b7cfe83 100644 --- a/src/CodoMetis.TypeKit/Refusals.cs +++ b/src/CodoMetis.TypeKit/Refusals.cs @@ -47,4 +47,7 @@ internal static class Refusals /// A closed set's All, or a member by name, read by the set's own static initializers. public const string ClosedSetInitializing = " See " + Readme + "a-closed-set-read-before-it-is-initialized"; + + /// AsText() in a process that cannot compose text: globalization-invariant mode. + public const string TextWithoutComposition = " See " + Readme + "astext-in-globalization-invariant-mode"; } diff --git a/src/CodoMetis.TypeKit/StringTextExtensions.cs b/src/CodoMetis.TypeKit/StringTextExtensions.cs index b9dd56a..46b087d 100644 --- a/src/CodoMetis.TypeKit/StringTextExtensions.cs +++ b/src/CodoMetis.TypeKit/StringTextExtensions.cs @@ -77,13 +77,29 @@ public static class StringTextExtensions /// The two zero-width joiners are kept: they are ordinary orthography in Persian, Arabic and /// several Indic scripts, and a zero-width joiner is what holds an emoji sequence together. /// + /// + /// Composing needs the runtime's globalization data. In globalization-invariant mode + /// (InvariantGlobalization in the project, or DOTNET_SYSTEM_GLOBALIZATION_INVARIANT) + /// returns its input unchanged, and one name would be kept in two + /// spellings, so this guard throws there rather than answer as if it had composed. Written text has + /// no guard in that mode: is for a value another system issued, and + /// refuses the joiners this one keeps. + /// /// /// /// The text, or for a null, empty or whitespace-only string, or /// for one carrying a character that renders as nothing or breaks /// the line. /// - public Result AsText() => Clean(text, tolerateJoiners: true, canonicalize: true); + /// + /// The process runs in globalization-invariant mode, where text cannot be composed, whatever the + /// string. Turn the mode off and make ICU available to the process; ICU alone does nothing while + /// the mode is on. + /// + public Result AsText() => + Composition.IsAvailable + ? Clean(text, tolerateJoiners: true, canonicalize: true) + : throw Composition.Unavailable(); /// /// The string as a value another system issued, a subject claim, a session id, a key: trimmed of @@ -95,7 +111,9 @@ public static class StringTextExtensions /// Nothing is composed or folded, since an issued value is compared against what its issuer /// sends next time, and canonicalising it alters an attested value. Trimming survives because /// surrounding whitespace is a transport artefact. The zero-width joiners - /// keeps are refused here, since no issuer needs one. + /// keeps are refused here, since no issuer needs one. Since nothing is + /// composed, this guard needs no globalization data and works in globalization-invariant mode, + /// where it is still the guard for issued values only, not for text a person wrote. /// /// /// Refused as , which lists them exactly, as @@ -278,4 +296,34 @@ or 0x0891 // ARABIC PIASTRE MARK ABOVE or 0x08E2 // ARABIC DISPUTED END OF AYAH or 0x110BD // KAITHI NUMBER SIGN or 0x110CD; // KAITHI NUMBER SIGN ABOVE + + /// + /// Whether this process composes text at all, asked once. In globalization-invariant mode + /// returns its input unchanged rather than throwing, and the + /// System.Globalization.Invariant switch is not set when the mode comes from the environment + /// variable, so the one reliable question is whether a decomposed letter composes. + /// + private static class Composition + { + public static readonly bool IsAvailable = Composes(); + + private static bool Composes() + { + try + { + return string.Concat("e", char.ConvertFromUtf32(0x0308)).Normalize(NormalizationForm.FormC) == char.ConvertFromUtf32(0x00EB); + } + catch (PlatformNotSupportedException) + { + return false; + } + } + + public static PlatformNotSupportedException Unavailable() => + new("AsText() composes text to Unicode normalization form C, and this process cannot: it runs in globalization-invariant mode " + + "(InvariantGlobalization in the project, or DOTNET_SYSTEM_GLOBALIZATION_INVARIANT), where string.Normalize returns its input unchanged " + + "and one name would be kept in two spellings. Written text has no guard in this mode. Turn invariant mode off and make ICU available " + + "to the process; ICU alone does nothing while the mode is on. AsIdentifier() is the guard for a value another system issued, not for text a person wrote." + + Refusals.TextWithoutComposition); + } } diff --git a/test/CodoMetis.TypeKit.Tests/InvariantGlobalizationTests.cs b/test/CodoMetis.TypeKit.Tests/InvariantGlobalizationTests.cs new file mode 100644 index 0000000..289d878 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/InvariantGlobalizationTests.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// AsText() where text cannot be composed. In globalization-invariant mode, which a Native AOT +/// application often turns on with InvariantGlobalization, string.Normalize returns its +/// input unchanged, and AsText() answered Zoe and a combining diaeresis as a success, +/// uncomposed: one name, two spellings, silently. It throws there now. +/// +/// +/// The mode is fixed when a process starts, so the test starts this test assembly again with +/// DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, filtered to itself, and holds the child to having +/// run it and passed. In that child, or in a whole run started in the mode, the same test makes its +/// assertions directly, after confirming that the mode is really on. +/// +public sealed class InvariantGlobalizationTests +{ + private const string InvariantMode = "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"; + + [Fact] + public async Task AsText_throws_where_text_cannot_be_composed_and_AsIdentifier_still_answers() + { + if (Environment.GetEnvironmentVariable(InvariantMode) != "1") + { + await RunInInvariantMode(nameof(AsText_throws_where_text_cannot_be_composed_and_AsIdentifier_still_answers)); + return; + } + + Should.Throw(() => CultureInfo.GetCultureInfo("de-DE"), "the process is not in globalization-invariant mode, so this run tests nothing"); + "Zoe\u0308".Normalize(System.Text.NormalizationForm.FormC).ShouldBe("Zoe\u0308", "string.Normalize composes in this process, so this run tests nothing"); + + var refusal = Should.Throw(() => "Zoe\u0308".AsText()); + refusal.Message.ShouldContain("globalization-invariant mode"); + refusal.Message.ShouldContain("InvariantGlobalization"); + refusal.Message.ShouldContain("Turn invariant mode off and make ICU available"); + refusal.Message.ShouldContain("ICU alone does nothing"); + RefusalLinkTests.ShouldEndWithLinkTo(refusal.Message, RefusalLinkTests.TextWithoutComposition); + + Should.Throw(() => "Zoe".AsText(), "whatever the string, so that the first call finds out"); + Should.Throw(() => ((string?)null).AsText(), "whatever the string, so that the first call finds out"); + + " Zoe\u0308 ".AsIdentifier().Match(id => id, fault => fault.ToString()).ShouldBe("Zoe\u0308", "AsIdentifier composes nothing and needs no globalization data"); + "Zoe\u200B".AsIdentifier().Match(id => id, fault => fault.ToString()).ShouldBe(nameof(TextFault.Hidden)); + } + + /// + /// Runs one test of this assembly in a process in globalization-invariant mode, with the dotnet host + /// of the runtime running this one, and fails unless it ran and passed. Started directly, the test + /// assembly is xunit's own console runner, whose filter and summary these are. + /// + private static async Task RunInInvariantMode(string method) + { + using var process = Process.Start(new ProcessStartInfo( + DotnetHost(), + ["exec", typeof(InvariantGlobalizationTests).Assembly.Location, + "-method", $"{typeof(InvariantGlobalizationTests).FullName}.{method}", + "-noLogo", "-noColor", "-noAutoReporters"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + Environment = { [InvariantMode] = "1", ["DOTNET_CLI_UI_LANGUAGE"] = "en" }, + })!; + + var output = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); + var errors = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); + await process.WaitForExitAsync(TestContext.Current.CancellationToken); + + var report = await output + await errors; + process.ExitCode.ShouldBe(0, $"The test failed in globalization-invariant mode:{Environment.NewLine}{report}"); + report.ShouldContain("Total: 1, Errors: 0, Failed: 0, Skipped: 0", Case.Sensitive, $"The child did not run this one test:{Environment.NewLine}{report}"); + } + + /// The dotnet host beside the shared runtime running this process, else the one on the PATH. + private static string DotnetHost() + { + var root = Path.GetFullPath(Path.Combine(RuntimeEnvironment.GetRuntimeDirectory(), "..", "..", "..")); + var host = Path.Combine(root, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + + return File.Exists(host) ? host : "dotnet"; + } +} diff --git a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs index 20798c2..2e0b615 100644 --- a/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs +++ b/test/CodoMetis.TypeKit.Tests/RefusalLinkTests.cs @@ -46,6 +46,12 @@ public sealed partial class RefusalLinkTests private const string ClosedSetInitializing = "A closed set read before it is initialized"; private const string TaskGivenToCatch = "A task given to Catch"; + /// + /// The one refusal no call in this process can make: AsText() refuses only in globalization-invariant + /// mode, so checks its link in a process started in that mode. + /// + internal const string TextWithoutComposition = "AsText in globalization-invariant mode"; + private enum Fault { TooLong } private readonly record struct Code; @@ -122,14 +128,23 @@ public void A_refusal_ends_its_message_with_a_link_to_its_subsection(string refu var thrown = Record.Exception(attempt).ShouldNotBeNull($"{refusal} threw nothing."); var own = thrown.GetBaseException(); - var message = OwnMessage(own); + var link = ShouldEndWithLinkTo(OwnMessage(own), heading, refusal); + + thrown.Message.ShouldContain(link, Case.Sensitive, $"{refusal}: the message the caller sees lost the link."); + } + + /// That ends with a link to 's subsection, and that the README has it. + /// The link. + internal static string ShouldEndWithLinkTo(string message, string heading, string? refusal = null) + { + refusal ??= heading; var link = LinkAtTheEnd().Match(message); link.Success.ShouldBeTrue($"{refusal}: the message does not end with a link to the package README: {message}"); link.Groups["anchor"].Value.ShouldBe(Slug(heading), $"{refusal} links to another subsection than '{heading}'."); Anchors().ShouldContain(link.Groups["anchor"].Value, $"{refusal} links to #{link.Groups["anchor"].Value}, which no heading of the README produces, so the link opens the top of the page."); - thrown.Message.ShouldContain(link.Value, Case.Sensitive, $"{refusal}: the message the caller sees lost the link."); + return link.Value; } /// A subsection nothing links to is either stale or a refusal that lost its link. @@ -140,7 +155,7 @@ public void Every_subsection_of_Why_does_this_throw_is_what_a_refusal_links_to() subsections.Count.ShouldBeGreaterThanOrEqualTo(7, $"The README has no '## {WhyDoesThisThrow}' with its subsections."); subsections.Select(Slug).Except([Slug(BuildErrorOnly)]).Order() - .ShouldBe(Refusals.Values.Select(entry => Slug(entry.Heading)).Distinct().Order(), + .ShouldBe(Refusals.Values.Select(entry => Slug(entry.Heading)).Append(Slug(TextWithoutComposition)).Distinct().Order(), "The subsections of the README and the headings the refusals link to differ."); } From 8f12e03e7a384c732f9bb9de9661b1004a23b192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:15:48 +0200 Subject: [PATCH 096/147] docs: the 2.0.0 changelog and the migration guide carry the review's analyzer, text-guard, Patch, Catch and fault-schema changes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 40 +++++++++++++++++++++++++++++++++------- docs/migrating-to-2.md | 10 ++++++++-- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b465dc7..e0028d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,13 @@ against 1.5.1 meets them. - `Catch(() => …)` runs the call and answers `Result`, or `Result` for an action. `Catch` catches that type only and lets any other propagate. - `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error too. - - An `OperationCanceledException` passes through every overload. A timeout, one over a - `TimeoutException` as `HttpClient` throws when its `Timeout` elapses, is the error. + - An `OperationCanceledException` passes through every overload, and so does an + `AggregateException` that holds only cancellations, as `Wait()` and `.Result` throw for a + cancelled task; one that holds anything beside them is the error, as thrown. A timeout, one over a + `TimeoutException` as `HttpClient` throws when its `Timeout` elapses, is the error, wrapped or + not, `.Result` on a timed-out call included. + - `CatchAsync`'s error is what `await` throws: for a task that faulted with several exceptions, + such as `Task.WhenAll`'s, the first of them, while the task's `Exception` holds them all. - A delegate that returns a `Task`, an `async` lambda among them, given to `Catch` or to its command form `Catch` is a compile error naming `CatchAsync`. So is a delegate that returns any value given to the command form, which would discard it. @@ -61,8 +66,9 @@ against 1.5.1 meets them. string enum. Nothing is registered per set. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0012, a closed set's `Match` given an argument by position. Every parameter has the same type, one delegate per member in declaration order, so reordering two - members swapped their branches at such a call and compiled. The code fix names every argument after - the parameter it binds to. + members swapped their branches at such a call and compiled. A conditional call is reported too, + `status?.Match(…)?.ToUpper()` among them. The code fix names every argument after the parameter it + binds to. - **`CodoMetis.TypeKit`**: `Patch`, a field of a JSON Merge Patch (RFC 7396): absent, null or a value, where System.Text.Json alone cannot tell an absent property from a null one. `default` is absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never @@ -72,7 +78,8 @@ against 1.5.1 meets them. absence, and `Traverse` validates a value with a `Create`'s own fault, `request.Email.Traverse(Email.Create)` as a `Result, EmailFault>`, a clear and an absence passing through; a `Map` selector that answers null is refused, since a clear is the Null - state and never a value. Writing an absent patch is refused, and + state and never a value. A value the patch cannot read is refused at the property's path + (`$.email`, `$[1].quantity`), with the nested refusal's own message. Writing an absent patch is refused, and `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out. Under a source-generated context a primitive, a string, a `Guid`, a date, a `Uri` and a value object need no listing; a class or an enum does. Works under Native AOT: the converter is registered by the closed type's static @@ -89,13 +96,16 @@ against 1.5.1 meets them. arguments written or inferred, a C# 14 extension member, and a generic attribute. A method that implements an interface's, directly, statically or through a base class, carries its constraint. A type parameter forwarded without the attribute is a breach too, so the requirement follows the generic chain; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. + Like every rule, it reports in generated code only where the code maps to a `.razor` or `.cshtml` + file. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0011, with the package's first code fix. A type marked `[RequireParameterlessConstructor]` (new in the base package, with `ConstructorAccessibility`) requires every concrete type that derives from or implements it, directly or through another, to have a parameterless constructor of the given accessibility, for the host that creates such types by reflection. A class that declares no constructor satisfies `Public` as it is; a missing constructor is reported at the type and the fix adds it, a wrong accessibility at the constructor - and the fix changes it; a type with a primary constructor gets no fix. The code fixes ship in + and the fix changes it; a type with a primary constructor gets no fix. A struct is never reported: + its parameterless constructor is public and can be nothing else. The code fixes ship in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` inside the analyzer package. - **`CodoMetis.TypeKit`**: the text guards. `text.AsText()` is for text a person wrote: trimmed, every space separator folded to an ordinary space, composed to NFC so one name has one spelling, and @@ -105,7 +115,15 @@ against 1.5.1 meets them. value another system issued, a subject claim or a session id: trimmed and otherwise left as it arrived, with the joiners refused too. A variation selector is kept after the character it varies and refused anywhere else, and a value of joiners, variation selectors and combining marks alone, - which renders nothing, is refused by both. Both answer `Result`, where `Blank` is + which renders nothing, is refused by both. Both also refuse, as `Hidden`, a control character (a tab + or a line break inside the value, so a multi-line field does not go through them), a format + character (the soft hyphen, the byte-order mark, the bidirectional marks), private use, the line and + paragraph separators, an unpaired surrogate and `U+FFFE`, the Khmer inherent vowels and the Khitan + filler, and more than 30 combining marks in a row, which composing would reorder in quadratic time; + the Arabic number sign and the other prepended concatenation marks are drawn and kept. No string + makes either throw. `AsText()` composes to NFC, not NFKC, and throws `PlatformNotSupportedException` + in globalization-invariant mode, where `string.Normalize` composes nothing: turn the mode off and + make ICU available, since written text has no guard there. Both answer `Result`, where `Blank` is the caller's to read as an error or as absence and `Hidden` is a refusal in both; a null receiver is `Blank`. A value object over either is one line, shown in the README. - **`CodoMetis.TypeKit`**: `FirstOrNone()`, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` @@ -187,6 +205,9 @@ against 1.5.1 meets them. - **`CodoMetis.TypeKit.Analyzers`**: CMTK0003 also reports a result picked by `??`, a dropped tuple holding one, a dropped dictionary of them, and a call's task passed through `ConfigureAwait` or `WaitAsync` and dropped, awaited or not; a task the caller still holds stays silent through both. + A chain of taps on a result or a task the caller holds, `result.Tap(log).TapError(warn);` or + `await pending.TapAsync(a).TapErrorAsync(b);`, is silent, since each tap returns its receiver; 1.x + reported it. A chain that starts at a call still reports. - **`CodoMetis.TypeKit.Generators`**: an abstract type that declares a value-object marker is CMTK1022, an Error. It generated nothing, and a value held as it was written as `{}`; declare the marker on the sealed type. @@ -201,6 +222,11 @@ against 1.5.1 meets them. generates its wrapped type's: over `sbyte`, `uint`, `ulong`, `ushort` and `char` too, and at the root of a TPH hierarchy on SQLite, which stored 0. A TPC key on SQLite is not generated, as a plain `int` key is not. A migration of such a model adds the identity. +- **`CodoMetis.TypeKit.AspNetCore`**: the OpenAPI document describes `fault`, an optional string, on + the problem schema the fault responses refer to, in 3.1 and 3.0 and inline where the host inlines + problem details, with a description of the form the host writes. 1.5's `ProblemDetails` component + had only ASP.NET's members, so a client generated from it never saw `fault`. A document without a + fault response is unchanged. - **`CodoMetis.TypeKit.AspNetCore`**: a document in which a nested value object's or closed set's component name, `Order.Status` as `OrderStatus`, is also another type's throws, naming both; one component described both as one of them. diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md index 3ceca44..8cd0ea6 100644 --- a/docs/migrating-to-2.md +++ b/docs/migrating-to-2.md @@ -40,6 +40,9 @@ _ = await orders.CancelAsync(id); // the drop, said await foreach (var _ in orders.CancelEachAsync(ids)) { } // a loop variable named _ says it too ``` +A chain of taps on a result or a task you hold, `result.Tap(log).TapError(warn);`, was reported by +1.x and no longer is, since each tap returns its receiver. + Every report is answered in the code and never needs a suppression: `_ =` for a drop nothing depends on, a cast to `Task` for a task converted on purpose (`((Task)orders.CancelAsync(id)).Wait(timeout)` waits without the result), a loop variable named `_`. To migrate @@ -153,8 +156,11 @@ package's namespace, makes a use ambiguous (CS0104): qualify one of them, or ali ## Fault responses -Nothing to change. `AddFaultResponses(fault => …)` maps an enum as it did, and answers with -the same response. `FaultProblem` takes any non-null fault type where it took `struct, Enum`, +Nothing to change in the code. `AddFaultResponses(fault => …)` maps an enum as it did, and +answers with the same response. The OpenAPI document of a host with fault responses describes +`fault`, an optional string, on the shared `ProblemDetails` component, so a client generated from it +gains an optional `fault` member on its problem type; ASP.NET's own problem responses never set it. +Regenerate clients, and update a checked-in snapshot of the document. `FaultProblem` takes any non-null fault type where it took `struct, Enum`, so that a closed set can be one, and every type argument it accepted still is. To answer each fault by its kind instead of by a switch, make the fault type a closed set: [Closed sets](closed-sets.md#as-a-fault-type). From d19972c1bf6681ea2542cd5a2587863efd1d5eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:16:35 +0200 Subject: [PATCH 097/147] fix: Fault.Of(null) says a fault's reason is never null, not that an Option or a Result never holds one Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit/Fault.cs | 2 +- test/CodoMetis.TypeKit.Tests/FaultTests.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CodoMetis.TypeKit/Fault.cs b/src/CodoMetis.TypeKit/Fault.cs index a7330fb..3d047de 100644 --- a/src/CodoMetis.TypeKit/Fault.cs +++ b/src/CodoMetis.TypeKit/Fault.cs @@ -33,7 +33,7 @@ namespace CodoMetis.TypeKit; /// is empty or white space: say for no detail. public Fault(TFault reason, string? detail = null) { - Reason = reason is null ? throw ThrowHelper.NullContent(nameof(reason)) : reason; + Reason = reason is null ? throw new ArgumentNullException(nameof(reason), "A fault's reason is one of its type's values; it is never null.") : reason; Detail = detail is not null && string.IsNullOrWhiteSpace(detail) ? throw new ArgumentException("A fault's detail is a sentence, or null for none; it is never blank.", nameof(detail)) : detail; diff --git a/test/CodoMetis.TypeKit.Tests/FaultTests.cs b/test/CodoMetis.TypeKit.Tests/FaultTests.cs index cf27349..f9b0d97 100644 --- a/test/CodoMetis.TypeKit.Tests/FaultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/FaultTests.cs @@ -85,6 +85,8 @@ public void A_null_reason_is_refused() var refusal = Should.Throw(() => Fault.Of(null!)); refusal.ParamName.ShouldBe("reason"); + refusal.Message.ShouldStartWith("A fault's reason"); + refusal.Message.ShouldNotContain("Option"); } [Fact] From 3bddba7665d2e71e3e677fc3f132118e0c7e6258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:52:39 +0200 Subject: [PATCH 098/147] fix: a value-object key at the root of an abstract TPC hierarchy is generated again The TPC convention looked for the root's own table to read the provider's sequence default, and an abstract root has none: it took the generation away, so on PostgreSQL the first entity was stored with 0 and the second refused as a duplicate. It now reads the first table the key is mapped to, as the providers' own conventions do. The key-generation model tests now build each shape for SQL Server too, and an abstract TPC root on every provider; a round trip on PostgreSQL numbers three entities from the sequence. Co-Authored-By: Claude Opus 5.5 --- Directory.Packages.props | 6 +- design/ef-core.md | 6 +- .../README.md | 5 +- ...ValueObjectKeyValueGenerationConvention.cs | 12 +- ...s.TypeKit.EntityFrameworkCore.Tests.csproj | 1 + .../KeyGenerationTests.cs | 116 +++++++++++++++--- .../PostgreSqlRoundTripTests.cs | 28 +++++ 7 files changed, 152 insertions(+), 22 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6c2b6a5..a4ebdf7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -49,12 +49,14 @@ + diff --git a/design/ef-core.md b/design/ef-core.md index adaf5f1..06c0fb8 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -67,7 +67,11 @@ hierarchy-mapping and shared-table convention: SQLite re-decides a key from its CLR type when the root's mapping strategy is set, which reset a TPH root's key to not generated, and a TPC key stays generated only where the provider gave it a sequence, as for a plain `int` - ([decision 58](adr/0058-integer-key-follows-ef-rule.md)). A key over a `Guid` is left alone: the application + ([decision 58](adr/0058-integer-key-follows-ef-rule.md)). The sequence is read in the first table + the key is mapped to, as the providers' own conventions read it: an abstract TPC root has no table + of its own, and reading the root's table took the generation away (2.0.0's review: on PostgreSQL + every entity was stored with 0). The model tests build each shape for SQLite, Npgsql and SQL + Server, an abstract TPC root included. A key over a `Guid` is left alone: the application assigns `OrderId.New()`, and a generated key that is already set makes EF take a new entity reached through a navigation for an existing one. - **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 5f4c32d..d821e6c 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -76,7 +76,7 @@ EF to generate it instead, configure `ValueGeneratedOnAdd()` on the property; EF entity whose key is already set, reached through a navigation, for an existing one. The root of an inheritance hierarchy follows the same rule under TPH and TPT. Under TPC, as for an `int` key, the key is generated only where the provider numbers the whole hierarchy from a sequence (PostgreSQL, SQL -Server); SQLite has none, so there the application assigns it. An explicit configuration always wins +Server), an abstract root's included; SQLite has none, so there the application assigns it. An explicit configuration always wins over these defaults. **SQLite: configure integer keys with `UseAutoincrement()`.** SQLite fills such a key in, but EF's @@ -145,7 +145,8 @@ value's order is meant (`OrderBy(o => o.Priority.Value)`), or order in memory af Equality is not ordering, and stays allowed. The mapping is tested on SQLite and PostgreSQL, the SQL of each translation is pinned on PostgreSQL, -and every wrapped-type family makes a round trip through a real PostgreSQL. +every wrapped-type family makes a round trip through a real PostgreSQL, and key generation is also +checked in SQL Server's model. ## An Option for nothing found diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs index 07776d9..ba9cbf4 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs @@ -99,10 +99,18 @@ private static bool IsGeneratedByTheDatabase(Type wrapped) => /// two entities of different types would share a key. /// /// +/// /// EF refuses to generate an integer key under TPC (RelationalValueGenerationConvention.MappingStrategyAllowsValueGeneration) /// unless the provider gives it a sequence, which PostgreSQL's and SQL Server's do, as the column's /// default (nextval, NEXT VALUE FOR), and SQLite's does not. That default is what this /// reads, after every provider convention, so no provider is named. +/// +/// +/// It reads it in the first table the key is mapped to, as the providers' own value-generation +/// conventions do (GetMappedStoreObjects): an abstract root has no table of its own, only its +/// derived types', and reading the root's own table found none and took a sequence's generation away +/// (measured with Npgsql 10.0.3: every entity was stored with 0). +/// /// internal sealed class ValueObjectTpcKeyValueGenerationConvention : IModelFinalizingConvention { @@ -114,8 +122,8 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven if (ValueObjectKeyValueGenerationConvention.GeneratedKey(entityType) is not { } property) continue; if (property.GetValueGeneratedConfigurationSource() != ConfigurationSource.Convention) continue; - if (StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) is not { } table - || RelationalValueGenerationConvention.GetValueGenerated(property, table) is null) + var table = property.GetMappedStoreObjects(StoreObjectType.Table).FirstOrDefault(); + if (table.Name is null || RelationalValueGenerationConvention.GetValueGenerated(property, table) is null) property.Builder.ValueGenerated(null); } } diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj index 53f74a4..5d9623a 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs index c51fb81..5fbe568 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs @@ -7,7 +7,8 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; /// /// A value-object key over an integer is generated as a key of that integer is, in every mapping of an -/// inheritance hierarchy, compared with the same hierarchy keyed by a plain int. +/// inheritance hierarchy, compared with the same hierarchy keyed by a plain int, in the models +/// of SQLite, Npgsql and SQL Server. /// /// /// EF decides a hierarchy root's key generation again when it sets the root's mapping strategy, in a @@ -19,19 +20,29 @@ public sealed class KeyGenerationTests : IDisposable { private readonly SqliteConnection _connection = new("Data Source=:memory:"); - public static TheoryData Hierarchies => new() + /// The providers a model is built for. SQL Server's is built without a server, for the model alone. + private static readonly string[] Providers = ["sqlite", "npgsql", "sqlserver"]; + + public static TheoryData Hierarchies { - { "sqlite", typeof(TphByValueObject), typeof(TphByInt) }, - { "sqlite", typeof(TptByValueObject), typeof(TptByInt) }, - { "sqlite", typeof(TpcByValueObject), typeof(TpcByInt) }, - { "npgsql", typeof(TphByValueObject), typeof(TphByInt) }, - { "npgsql", typeof(TptByValueObject), typeof(TptByInt) }, - { "npgsql", typeof(TpcByValueObject), typeof(TpcByInt) }, - }; + get + { + var hierarchies = new TheoryData(); + foreach (var provider in Providers) + { + hierarchies.Add(provider, typeof(TphByValueObject), typeof(TphByInt)); + hierarchies.Add(provider, typeof(TptByValueObject), typeof(TptByInt)); + hierarchies.Add(provider, typeof(TpcByValueObject), typeof(TpcByInt)); + hierarchies.Add(provider, typeof(AbstractTpcByValueObject), typeof(AbstractTpcByLong)); + } + return hierarchies; + } + } /// - /// The same decision as for int: generated under TPH and TPT on both providers, and under TPC - /// only on PostgreSQL, from a sequence, since SQLite has none and each table would number its own rows. + /// The same decision as for the wrapped type: generated under TPH and TPT on every provider, and under + /// TPC only on PostgreSQL and SQL Server, from a sequence, since SQLite has none and each table would + /// number its own rows. An abstract TPC root included, whose key is mapped only to its derived types' tables. /// [Theory] [MemberData(nameof(Hierarchies))] @@ -47,6 +58,24 @@ public void A_hierarchy_root_s_key_is_generated_as_an_int_key_is(string provider .ShouldBe(intKey.GetDefaultValueSql()?.Replace(keyedByInt.Name, "Root", StringComparison.Ordinal), $"{keyedByValueObject.Name} on {provider}"); } + /// + /// An abstract root has no table of its own, only its derived types' tables, and the provider's + /// sequence is the key's default in each of them: the convention looked for the root's own table, + /// found none, and took the generation away, so on PostgreSQL every entity was stored with 0. + /// + [Theory] + [InlineData("npgsql", """nextval('"AbstractTpcByValueObjectSequence"')""")] + [InlineData("sqlserver", "NEXT VALUE FOR [AbstractTpcByValueObjectSequence]")] + public void An_abstract_TPC_root_s_key_is_generated_from_its_sequence(string provider, string sequenceDefault) + { + using var db = Create(provider); + + var key = Key(db, typeof(AbstractTpcByValueObject)); + + key.ValueGenerated.ShouldBe(ValueGenerated.OnAdd); + key.GetDefaultValueSql().ShouldBe(sequenceDefault); + } + /// What the review reproduced, on a database: a base entity and a derived one, each given its own key. [Fact] public void On_SQLite_a_TPH_root_s_key_is_generated_by_the_database() @@ -86,7 +115,7 @@ public static TheoryData IntegerKeys get { var keys = new TheoryData(); - foreach (var provider in new[] { "sqlite", "npgsql" }) + foreach (var provider in Providers) foreach (var (byValueObject, byWrapped) in IntegerKeysDb.Pairs) keys.Add(provider, byValueObject, byWrapped); return keys; @@ -141,8 +170,10 @@ public void On_SQLite_a_key_over_each_integer_type_is_generated_by_the_database( private DbContextOptions Options(string provider) where TDb : DbContext => provider switch { - "sqlite" => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options, - _ => new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options, + "sqlite" => new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options, + "npgsql" => new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options, + "sqlserver" => new DbContextOptionsBuilder().UseSqlServer("Server=localhost;Database=none").UseTypeKit().Options, + _ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null), }; private static IProperty Key(DbContext db, Type root) => db.Model.FindEntityType(root)!.FindPrimaryKey()!.Properties.Single(); @@ -220,7 +251,56 @@ public class TpcByIntDerived : TpcByInt public string Extra { get; set; } = ""; } -/// Each mapping strategy twice, keyed by a value object over int and by int; TPH is EF's default. +/// A TPC root that is abstract, so mapped to no table of its own: the review's shape, keyed over long. +public abstract class AbstractTpcByValueObject +{ + public Int64Key Id { get; set; } + + public string Name { get; set; } = ""; +} + +public sealed class AbstractTpcByValueObjectCar : AbstractTpcByValueObject +{ + public int Doors { get; set; } +} + +public sealed class AbstractTpcByValueObjectBike : AbstractTpcByValueObject +{ + public int Gears { get; set; } +} + +public abstract class AbstractTpcByLong +{ + public long Id { get; set; } + + public string Name { get; set; } = ""; +} + +public sealed class AbstractTpcByLongCar : AbstractTpcByLong +{ + public int Doors { get; set; } +} + +public sealed class AbstractTpcByLongBike : AbstractTpcByLong +{ + public int Gears { get; set; } +} + +/// The abstract TPC root alone, for a database. +public sealed class AbstractTpcDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} + +/// +/// Each mapping strategy twice, keyed by a value object over int and by int; TPH is EF's +/// default. And an abstract TPC root twice, over long. +/// public sealed class HierarchyKeysDb(DbContextOptions options) : DbContext(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -237,6 +317,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(); modelBuilder.Entity().UseTpcMappingStrategy(); modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity().UseTpcMappingStrategy(); + modelBuilder.Entity(); + modelBuilder.Entity(); } } diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs index 1e4994c..cbaeef5 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs @@ -124,6 +124,27 @@ public async Task An_integer_key_is_an_identity_column() second.Id.ShouldNotBe(first.Id); } + /// + /// An abstract TPC root's key is numbered from the hierarchy's sequence, across its derived types' + /// tables, as a long key is. Without the generation the first car was stored with 0 and the + /// second refused as a duplicate. + /// + [Fact] + public async Task An_abstract_TPC_root_s_key_is_numbered_from_its_sequence() + { + var cancellation = TestContext.Current.CancellationToken; + + await using var db = new AbstractTpcDb(database.Options("typekit_tpc")); + await db.Database.EnsureCreatedAsync(cancellation); + + AbstractTpcByValueObject[] vehicles = + [new AbstractTpcByValueObjectCar { Name = "a" }, new AbstractTpcByValueObjectBike { Name = "b" }, new AbstractTpcByValueObjectCar { Name = "c" }]; + db.AddRange(vehicles); + await db.SaveChangesAsync(cancellation); + + vehicles.Select(vehicle => vehicle.Id.Value).ShouldBe([1L, 2L, 3L], ignoreOrder: true); + } + /// One PostgreSQL container and one schema for the class. public sealed class Database : IAsyncLifetime { @@ -132,6 +153,13 @@ public sealed class Database : IAsyncLifetime public TestDb Create() => new(_options ?? throw new InvalidOperationException("The database is not started.")); + /// Another database in the same container, for a model of its own. + public DbContextOptions Options(string databaseName) where TDb : DbContext => + new DbContextOptionsBuilder() + .UseNpgsql(new NpgsqlConnectionStringBuilder(_container.GetConnectionString()) { Database = databaseName }.ConnectionString) + .UseTypeKit() + .Options; + public async Task<(Customer Customer, Order Order)> Seed(Action? adjust = null) { var customer = new Customer { Id = CustomerId.New(), Name = $"customer {Guid.NewGuid():N}" }; From fe82f36aa6ed18feafe04abfa93672e42fbed6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:54:10 +0200 Subject: [PATCH 099/147] test: a value-object key that is also a foreign key is never generated, as a plain one is not A one-to-one sharing its principal's key, one splitting its principal's table and an owned type, each beside the same shape keyed by int, in the models of SQLite, Npgsql and SQL Server. Without the convention's foreign-key check the three were generated on add on SQLite; the suite had stayed green. Co-Authored-By: Claude Opus 5.5 --- .../KeyGenerationTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs index 5fbe568..62eb53a 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs @@ -164,6 +164,40 @@ public void On_SQLite_a_key_over_each_integer_type_is_generated_by_the_database( ulongs.Select(e => e.Id.Value).ShouldBe([1ul, 2ul]); } + public static TheoryData ForeignKeyKeys + { + get + { + var keys = new TheoryData(); + foreach (var provider in Providers) + foreach (var (byValueObject, byWrapped) in ForeignKeyKeysDb.Pairs) + keys.Add(provider, byValueObject, byWrapped); + return keys; + } + } + + /// + /// A key that is also a foreign key takes its value from the principal, so EF never generates it, + /// and a key over an integer is no exception: the dependent of a one-to-one sharing its principal's + /// key, one splitting its principal's table, and an owned type, whose key is its owner's. + /// + /// + /// Without the convention's foreign-key check each of them was generated on add on SQLite (measured); + /// Npgsql and SQL Server reset it themselves when they choose the key's value-generation strategy. + /// + [Theory] + [MemberData(nameof(ForeignKeyKeys))] + public void A_key_that_is_also_a_foreign_key_is_never_generated(string provider, Type dependentByValueObject, Type dependentByWrapped) + { + using var db = new ForeignKeyKeysDb(Options(provider)); + + var valueObjectKey = Key(db, dependentByValueObject); + + valueObjectKey.IsForeignKey().ShouldBeTrue(); + valueObjectKey.ValueGenerated.ShouldBe(Key(db, dependentByWrapped).ValueGenerated, $"{dependentByValueObject.Name} on {provider}"); + valueObjectKey.ValueGenerated.ShouldBe(ValueGenerated.Never, $"{dependentByValueObject.Name} on {provider}"); + } + public void Dispose() => _connection.Dispose(); private HierarchyKeysDb Create(string provider) => new(Options(provider)); @@ -412,3 +446,55 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(); } } + +public class SharedKeyPrincipal { public ShipmentNo Id { get; set; } } + +public class SharedKeyDependent { public ShipmentNo Id { get; set; } } + +public class SharedKeyPrincipalInt { public int Id { get; set; } } + +public class SharedKeyDependentInt { public int Id { get; set; } } + +public class SplitHead { public ShipmentNo Id { get; set; } } + +public class SplitBody { public ShipmentNo Id { get; set; } } + +public class SplitHeadInt { public int Id { get; set; } } + +public class SplitBodyInt { public int Id { get; set; } } + +public class Owner { public ShipmentNo Id { get; set; } public OwnedPart Part { get; set; } = new(); } + +public class OwnedPart { public string Note { get; set; } = ""; } + +public class OwnerInt { public int Id { get; set; } public OwnedPartInt Part { get; set; } = new(); } + +public class OwnedPartInt { public string Note { get; set; } = ""; } + +/// +/// Three dependents whose key is a foreign key to their principal's, each beside the same shape keyed +/// by int: a one-to-one sharing the key, a one-to-one splitting the table, and an owned type. +/// +public sealed class ForeignKeyKeysDb(DbContextOptions options) : DbContext(options) +{ + public static readonly (Type ByValueObject, Type ByWrapped)[] Pairs = + [ + (typeof(SharedKeyDependent), typeof(SharedKeyDependentInt)), + (typeof(SplitBody), typeof(SplitBodyInt)), + (typeof(OwnedPart), typeof(OwnedPartInt)), + ]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasOne().WithOne().HasForeignKey(d => d.Id); + modelBuilder.Entity().HasOne().WithOne().HasForeignKey(d => d.Id); + + modelBuilder.Entity().ToTable("Split").HasOne().WithOne().HasForeignKey(b => b.Id); + modelBuilder.Entity().ToTable("Split"); + modelBuilder.Entity().ToTable("SplitInt").HasOne().WithOne().HasForeignKey(b => b.Id); + modelBuilder.Entity().ToTable("SplitInt"); + + modelBuilder.Entity().OwnsOne(o => o.Part); + modelBuilder.Entity().OwnsOne(o => o.Part); + } +} From 2d8cc1f52d3c5c27b1e224e77a74529f6e93b858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:57:16 +0200 Subject: [PATCH 100/147] test: ordering a hand-written CompareTo over a navigation is refused, as Enumerable's operators Every refused case went through a DbSet, so the interceptor's Enumerable branch could be removed with the suite green. Over a navigation the operators are Enumerable's: without the branch, OrderBy(Rank).First() answered 1 where memory answers 3, and Max(Rank) 3 where memory answers 1. Ordering a navigation by .Value still translates. Co-Authored-By: Claude Opus 5.5 --- .../HandWrittenOrderingTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index d374034..4a9d497 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -33,6 +33,11 @@ public sealed class HandWrittenOrderingTests : IDisposable ["Min"] = db => db.Ranked.Min(r => r.Rank), ["Max"] = db => db.Ranked.Select(r => r.Rank).Max(), ["a record class"] = db => db.Ranked.OrderBy(r => r.Label).ToList(), + + // Over a navigation the operators are Enumerable's: the first answered 1 where memory answers 3, + // the second 3 where memory answers 1. + ["OrderBy over a navigation"] = db => db.Parents.Select(p => p.Children.OrderBy(c => c.Rank).Select(c => c.Rank.Value).First()).ToList(), + ["Max over a navigation"] = db => db.Parents.Select(p => p.Children.Max(c => c.Rank)).ToList(), }; private readonly SqliteConnection _connection = new("Data Source=:memory:"); @@ -70,6 +75,9 @@ public void Ordering_by_Value_equality_and_a_generated_ordering_still_translate( db.Ranked.Where(r => r.Count > ProbeCount.From(1)).OrderBy(r => r.Count).Select(r => r.Count.Value).ToList().ShouldBe([2, 3]); db.Ranked.Max(r => r.Count).ShouldBe(ProbeCount.From(3)); + + db.Parents.Select(p => p.Children.OrderByDescending(c => c.Rank.Value).Select(c => c.Rank.Value).First()).ToList().ShouldBe([3]); + db.Parents.Select(p => p.Children.Max(c => c.Rank.Value)).ToList().ShouldBe([3]); } /// Two value objects captured from the query's closure compare in memory, before EF sees the query. @@ -93,6 +101,8 @@ private RankedDb Seeded() foreach (var value in new[] { 1, 2, 3 }) db.Add(new Ranked { Rank = ProbeDescending.From(value), Count = ProbeCount.From(value), Label = ProbeReversedLabel.From($"{value}") }); + db.Add(new RankParent { Children = [new RankChild { Rank = ProbeDescending.From(1) }, new RankChild { Rank = ProbeDescending.From(3) }] }); + db.SaveChanges(); db.ChangeTracker.Clear(); @@ -116,7 +126,25 @@ public sealed class Ranked public ProbeCount Count { get; set; } } +public sealed class RankParent +{ + public int Id { get; set; } + + public List Children { get; set; } = []; +} + +public sealed class RankChild +{ + public int Id { get; set; } + + public int ParentId { get; set; } + + public ProbeDescending Rank { get; set; } +} + public sealed class RankedDb(DbContextOptions options) : DbContext(options) { public DbSet Ranked => Set(); + + public DbSet Parents => Set(); } From 719ee82c72878c2f011180fa6392adf123fcddeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:01:01 +0200 Subject: [PATCH 101/147] fix: a query filter that orders by a hand-written CompareTo is refused when the model is built EF adds an entity type's query filters after QueryCompilationStarting, so the interceptor never saw them: HasQueryFilter(r => r.Rank > From(2)) kept the rows the column's order keeps, 3 where memory keeps 1. A model-finalized convention now runs the same finder over every declared query filter, named or not, and throws the same refusal naming the filter and the entity type. A comparison in a filter that reads neither the entity nor a query is left alone, since EF evaluates it in memory by CompareTo (measured). The convention set plugin, now holding more than the key conventions, is TypeKitConventionSetPlugin. Co-Authored-By: Claude Opus 5.5 --- design/ef-core.md | 10 +- docs/ef-core.md | 3 +- .../HandWrittenOrderingInterceptor.cs | 108 ++++++++++++++++-- .../README.md | 4 +- .../TypeKitConventionSetPlugin.cs | 46 ++++++++ .../TypeKitServiceCollectionExtensions.cs | 2 +- ...ValueObjectKeyValueGenerationConvention.cs | 35 ------ .../HandWrittenOrderingTests.cs | 85 ++++++++++++++ 8 files changed, 245 insertions(+), 48 deletions(-) create mode 100644 src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs diff --git a/design/ef-core.md b/design/ef-core.md index 06c0fb8..b715725 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -48,8 +48,14 @@ - **A hand-written ordering is refused in a query (decided 2026-10-13, [decision 57](adr/0057-hand-written-ordering-refused-in-query.md)).** EF translates a comparison, an ordering and `Min`/`Max` to the column's order, not to a hand-written `CompareTo`. The comparable aspect marks such a type `[HandWrittenOrdering]`, and an - `IQueryExpressionInterceptor` that `UseTypeKit()` registers refuses those operators on it before - translation, naming the value object; ordering by `.Value` stays allowed. + `IQueryExpressionInterceptor` that `UseTypeKit()` registers refuses those operators on it, + `Queryable`'s and `Enumerable`'s over a navigation alike, before translation, naming the value + object; ordering by `.Value` stays allowed. EF adds a query filter after the interceptor has seen + the query, so `HasQueryFilter(r => r.Rank > From(2))` kept the column's rows (2.0.0's review); a + model-finalized convention refuses such a filter, named or not, naming the entity type, when the + model is built (and so at `dbcontext optimize` and `migrations add`). A comparison in a filter that + reads neither the entity nor a query is left alone: EF evaluates it in memory, by `CompareTo` + (measured: `Floor > From(2)` on the context kept the rows memory keeps). - **The element of a primitive collection is converted, not re-typed (decided 2026-09-28).** EF infers a collection's element mapping from the columns that read its table expression (`unnest`, `json_each`, `OPENJSON`, `VALUES`), and a re-typed element column handed it the wrapped type's diff --git a/docs/ef-core.md b/docs/ef-core.md index 2d4bced..eccd665 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -150,7 +150,8 @@ Ordering in SQL follows the column's collation, while the generated comparison i for a string, as for any string column. A value object with its own `CompareTo(TSelf)` cannot be compared, ordered, or taken the `Min` or `Max` of in a query, since the database orders the column by the wrapped value: such a query is refused, naming the value object, and `.Value` says the wrapped -value's order is meant (`OrderBy(i => i.Priority.Value)`). +value's order is meant (`OrderBy(i => i.Priority.Value)`). A query filter that does the same is +refused when the model is built. ## Reading back without validation diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index e887854..f73c2cb 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -2,6 +2,8 @@ using System.Reflection; using CodoMetis.TypeKit.CompilerServices; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; namespace CodoMetis.TypeKit.EntityFrameworkCore; @@ -23,22 +25,66 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore; /// operator's method, and an ordering key is a column, not a call. QueryCompilationStarting sees /// the LINQ tree once per query shape, before any translation, with the value objects' types intact; /// a precompiled query (Native AOT) passes through it at dbcontext optimize, which then fails. +/// It does not see the query filters EF adds later, which refuses. /// /// internal sealed class HandWrittenOrderingInterceptor : IQueryExpressionInterceptor { public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) { - new Finder().Visit(queryExpression); + HandWrittenOrdering.RefuseIn(queryExpression); return queryExpression; } +} - private sealed class Finder : ExpressionVisitor +/// +/// Refuses a query filter that compares, orders, or takes the minimum or maximum of a value object whose +/// ordering is hand-written, when the model is built, named filters included. +/// +/// +/// EF adds an entity type's query filters to a query after QueryCompilationStarting, so +/// never saw them: HasQueryFilter(r => r.Rank > From(2)) +/// kept the rows the column's order keeps, the opposite of the in-memory one for a descending +/// CompareTo (measured with EF Core 10.0.12 on SQLite). A filter is part of the model, and applies +/// to every query of its entity type and of the navigations that reach it, so it is refused where the +/// model is built, as an invalid configuration is; a compiled model is refused at dbcontext optimize. +/// +internal sealed class HandWrittenOrderingQueryFilterConvention : IModelFinalizedConvention +{ + public IModel ProcessModelFinalized(IModel model) + { + foreach (var entityType in model.GetEntityTypes()) + foreach (var filter in entityType.GetDeclaredQueryFilters()) + if (filter.Expression is { } expression) + HandWrittenOrdering.RefuseInFilter(expression, filter.IsAnonymous + ? $"in the query filter of {entityType.DisplayName()}" + : $"in the query filter \"{filter.Key}\" of {entityType.DisplayName()}"); + + return model; + } +} + +/// Finds a comparison, an ordering, or a minimum or maximum of a value object with a hand-written ordering, and throws. +internal static class HandWrittenOrdering +{ + /// In a query's LINQ tree, which EF has already stripped of what it evaluates in memory. + public static void RefuseIn(Expression query) => + new Finder("in a query", ", or do it in memory after the query", skipsWhatRunsInMemory: false).Visit(query); + + /// + /// In a query filter, where EF has not yet evaluated what reads nothing of the database: a comparison of + /// the context's own values runs in memory, by CompareTo, and is not refused. + /// + public static void RefuseInFilter(LambdaExpression filter, string where) => + new Finder(where, "", skipsWhatRunsInMemory: true).Visit(filter); + + private sealed class Finder(string where, string orElse, bool skipsWhatRunsInMemory) : ExpressionVisitor { protected override Expression VisitBinary(BinaryExpression node) { - if (node.NodeType is ExpressionType.LessThan or ExpressionType.LessThanOrEqual or ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual) + if (node.NodeType is ExpressionType.LessThan or ExpressionType.LessThanOrEqual or ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual + && !RunsInMemory(node)) { Refuse(node.Left.Type, $"compared with {Operator(node.NodeType)}"); Refuse(node.Right.Type, $"compared with {Operator(node.NodeType)}"); @@ -53,8 +99,11 @@ protected override Expression VisitMethodCall(MethodCallExpression node) if (method.Name is nameof(IComparable.CompareTo) or nameof(IComparer<>.Compare)) { - if (node.Object is not null) Refuse(node.Object.Type, $"compared with {method.Name}"); - foreach (var argument in node.Arguments) Refuse(argument.Type, $"compared with {method.Name}"); + if (!RunsInMemory(node)) + { + if (node.Object is not null) Refuse(node.Object.Type, $"compared with {method.Name}"); + foreach (var argument in node.Arguments) Refuse(argument.Type, $"compared with {method.Name}"); + } } else if ((method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable)) && OrderedType(method) is { } ordered) { @@ -64,6 +113,12 @@ protected override Expression VisitMethodCall(MethodCallExpression node) return base.VisitMethodCall(node); } + /// + /// Whether EF evaluates the comparison before translating, in a filter: it reads no parameter + /// bound outside it (the entity) and no query (a DbSet of the context). + /// + private bool RunsInMemory(Expression comparison) => skipsWhatRunsInMemory && !DatabaseReads.In(comparison); + /// The type a LINQ operator orders by: the key, the element for Order, the result for Min and Max. private static Type? OrderedType(MethodInfo method) => method.Name switch { @@ -74,7 +129,7 @@ or nameof(Queryable.MinBy) or nameof(Queryable.MaxBy) when method.IsGenericMetho _ => null, }; - private static void Refuse(Type type, string how) + private void Refuse(Type type, string how) { var candidate = Nullable.GetUnderlyingType(type) ?? type; @@ -83,9 +138,9 @@ private static void Refuse(Type type, string how) var wrapped = ValueObjectTypes.Describe(candidate)!.WrappedType; throw new InvalidOperationException( - $"{candidate.Name} is {how} in a query, but its ordering is its own CompareTo, which the database cannot run: " + $"{candidate.Name} is {how} {where}, but its ordering is its own CompareTo, which the database cannot run: " + $"there the column orders by the {wrapped.Name} it holds, which need not be {candidate.Name}'s order. " - + $"Compare or order by .Value explicitly where the {wrapped.Name}'s order is meant, or do it in memory after the query."); + + $"Compare or order by .Value explicitly where the {wrapped.Name}'s order is meant{orElse}."); } private static string Operator(ExpressionType type) => type switch @@ -96,4 +151,41 @@ private static void Refuse(Type type, string how) _ => ">=", }; } + + /// Whether an expression reads a parameter it does not declare itself, or a query. + private sealed class DatabaseReads : ExpressionVisitor + { + private readonly HashSet _declared = []; + private bool _found; + + public static bool In(Expression expression) + { + var reads = new DatabaseReads(); + reads.Visit(expression); + + return reads._found; + } + + public override Expression? Visit(Expression? node) + { + if (_found || node is null) return node; + if (typeof(IQueryable).IsAssignableFrom(node.Type)) _found = true; + + return _found ? node : base.Visit(node); + } + + protected override Expression VisitLambda(Expression node) + { + _declared.UnionWith(node.Parameters); + + return base.VisitLambda(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (!_declared.Contains(node)) _found = true; + + return node; + } + } } diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index d821e6c..6ffa203 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -142,7 +142,9 @@ database cannot run: the column orders by the wrapped value, the opposite of a d `ThenBy`, their descending forms, `Order`) or takes its `Min` or `Max` is therefore refused with an `InvalidOperationException` naming the value object. Compare or order by `.Value` where the wrapped value's order is meant (`OrderBy(o => o.Priority.Value)`), or order in memory after the query. -Equality is not ordering, and stays allowed. +Equality is not ordering, and stays allowed. A query filter (`HasQueryFilter`, named or not) that +does the same is refused when the model is built, naming the entity type, since it applies to every +query of that type. The mapping is tested on SQLite and PostgreSQL, the SQL of each translation is pinned on PostgreSQL, every wrapped-type family makes a round trip through a real PostgreSQL, and key generation is also diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs new file mode 100644 index 0000000..220c82e --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// Adds TypeKit's conventions to every model UseTypeKit() builds: the key generation of +/// and , +/// and the refusal of . +/// +internal sealed class TypeKitConventionSetPlugin : IConventionSetPlugin +{ + public ConventionSet ModifyConventions(ConventionSet conventionSet) + { + var finalizing = conventionSet.ModelFinalizingConventions; + + // After the last of EF's finalizing conventions that change what its value-generation convention + // re-reads, and so before a provider's value-generation strategy (an identity column on PostgreSQL + // and SQL Server), which is chosen for keys already generated on add and comes after them. + finalizing.Insert(IndexAfterTheLastReset(finalizing), new ValueObjectKeyValueGenerationConvention()); + + // Last of all: a TPC key is generated only where the provider gave it a sequence. + finalizing.Add(new ValueObjectTpcKeyValueGenerationConvention()); + + // On the finished model, whose query filters are final. + conventionSet.ModelFinalizedConventions.Add(new HandWrittenOrderingQueryFilterConvention()); + + return conventionSet; + } + + /// + /// EF's value-generation convention decides a key again, from its CLR type, whenever an entity type's + /// mapping strategy or table changes (RelationalValueGenerationConvention.ProcessEntityTypeAnnotationChanged), + /// and two finalizing conventions change them: sets + /// the mapping strategy on every hierarchy's root, TPH where none is configured, and + /// makes table names unique. Decided before them, the key of a TPH + /// root went back to never generated on SQLite, whose provider reads the CLR type, and the second + /// entity added threw for a duplicate key (measured with EF Core 10.0.12). + /// + private static int IndexAfterTheLastReset(List finalizing) + { + var last = finalizing.FindLastIndex(convention => convention is EntityTypeHierarchyMappingConvention or SharedTableConvention); + + return last + 1; + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs index fcf0be1..dd659b6 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs @@ -28,7 +28,7 @@ public static IServiceCollection AddEntityFrameworkTypeKit(this IServiceCollecti .TryAdd() .TryAdd() .TryAdd() - .TryAdd() + .TryAdd() .TryAdd(); return serviceCollection; diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs index ba9cbf4..8cf4156 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs @@ -6,41 +6,6 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore; -/// Adds to every model UseTypeKit() builds. -internal sealed class ValueObjectKeyConventionSetPlugin : IConventionSetPlugin -{ - public ConventionSet ModifyConventions(ConventionSet conventionSet) - { - var finalizing = conventionSet.ModelFinalizingConventions; - - // After the last of EF's finalizing conventions that change what its value-generation convention - // re-reads, and so before a provider's value-generation strategy (an identity column on PostgreSQL - // and SQL Server), which is chosen for keys already generated on add and comes after them. - finalizing.Insert(IndexAfterTheLastReset(finalizing), new ValueObjectKeyValueGenerationConvention()); - - // Last of all: a TPC key is generated only where the provider gave it a sequence. - finalizing.Add(new ValueObjectTpcKeyValueGenerationConvention()); - - return conventionSet; - } - - /// - /// EF's value-generation convention decides a key again, from its CLR type, whenever an entity type's - /// mapping strategy or table changes (RelationalValueGenerationConvention.ProcessEntityTypeAnnotationChanged), - /// and two finalizing conventions change them: sets - /// the mapping strategy on every hierarchy's root, TPH where none is configured, and - /// makes table names unique. Decided before them, the key of a TPH - /// root went back to never generated on SQLite, whose provider reads the CLR type, and the second - /// entity added threw for a duplicate key (measured with EF Core 10.0.12). - /// - private static int IndexAfterTheLastReset(List finalizing) - { - var last = finalizing.FindLastIndex(convention => convention is EntityTypeHierarchyMappingConvention or SharedTableConvention); - - return last + 1; - } -} - /// /// A single-column primary key that is a value object over an integer is generated on add, as a key /// of that integer type is: EF's own convention looks at the property's CLR type and leaves every diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index 4a9d497..979d308 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -90,6 +90,49 @@ public void A_comparison_EF_evaluates_in_memory_is_not_refused() db.Ranked.Count(r => one > Two).ShouldBe(3); } + /// + /// EF adds a query filter to the query after the interceptor has seen it, so a filter that compares + /// such a value object answered by the column's order: Rank > From(2) kept 3 where memory + /// keeps 1. The filter is refused when the model is built, named or not, naming the entity type. + /// + [Theory] + [InlineData(typeof(FilteredByRankDb), "the query filter of FilteredRank")] + [InlineData(typeof(NamedFilterByRankDb), "the query filter \"AboveTwo\" of FilteredRank")] + [InlineData(typeof(FilteredByChildRankDb), "the query filter of RankParent")] + public void A_query_filter_that_orders_by_its_own_CompareTo_is_refused(Type context, string filter) + { + _connection.Open(); + using var db = (DbContext)Activator.CreateInstance(context, new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options)!; + + var refusal = Should.Throw(() => + { + db.Database.EnsureCreated(); + return db.Set().Select(r => r.Rank.Value).ToList(); + }).Message; + + refusal.ShouldContain($"ProbeDescending is compared with > in {filter}"); + refusal.ShouldContain("its ordering is its own CompareTo, which the database cannot run"); + refusal.ShouldContain("Compare or order by .Value explicitly"); + } + + /// + /// A filter by .Value is the column's order, and a comparison that reads nothing of the entity + /// is evaluated in memory, where CompareTo runs: Floor > From(2) is true for the + /// descending order, so the rows above 1 are kept, where SQL's 1 > 2 would have kept none. + /// + [Fact] + public void A_query_filter_by_Value_or_in_memory_is_not_refused() + { + _connection.Open(); + using var db = new FilteredByValueDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + db.Database.EnsureCreated(); + foreach (var value in new[] { 1, 2, 3 }) db.Add(new FilteredRank { Rank = ProbeDescending.From(value) }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + db.Set().Select(r => r.Rank.Value).Order().ToList().ShouldBe([2, 3]); + } + public void Dispose() => _connection.Dispose(); private RankedDb Seeded() @@ -148,3 +191,45 @@ public sealed class RankedDb(DbContextOptions options) : DbContext(opt public DbSet Parents => Set(); } + +public sealed class FilteredRank +{ + public int Id { get; set; } + + public ProbeDescending Rank { get; set; } +} + +/// A query filter that compares by the hand-written ordering. +public sealed class FilteredByRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank > ProbeDescending.From(2)); +} + +/// The same as a named filter, beside one that is allowed. +public sealed class NamedFilterByRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity() + .HasQueryFilter("Positive", r => r.Rank.Value > 0) + .HasQueryFilter("AboveTwo", r => r.Rank > ProbeDescending.From(2)); +} + +/// A filter on another entity type, over its navigation. +public sealed class FilteredByChildRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().HasQueryFilter(p => p.Children.Max(c => c.Rank) > ProbeDescending.From(2)); + } +} + +/// Filters that are allowed: by .Value, and a comparison of the context's own value, evaluated in memory. +public sealed class FilteredByValueDb(DbContextOptions options) : DbContext(options) +{ + public ProbeDescending Floor { get; set; } = ProbeDescending.From(1); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => Floor > ProbeDescending.From(2) && r.Rank.Value > 1); +} From a91ad7f5e36679e9009c02b5fb3cdb15adde30da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:02:04 +0200 Subject: [PATCH 102/147] test: a facet configured on a closed set's column is kept on PostgreSQL A maximum length on a closed-set key and on a property, and a column type, each read from Npgsql's model. Looking the text mapping up without the facets kept the suite green while Npgsql answered text for all three; facets were tested for a value object only. Co-Authored-By: Claude Opus 5.5 --- .../ClosedSetMappingTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs index a6b4098..ecd2212 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/ClosedSetMappingTests.cs @@ -102,6 +102,45 @@ public void A_member_inside_a_JSON_column_is_its_name_and_a_query_compares_by_it } } + /// + /// A facet configured on a closed set's column is kept, as on any string column: the text mapping is + /// looked up with the facets. Looked up without them, Npgsql answered text for each. + /// + [Theory] + [InlineData(nameof(FacetRow.Id), "character varying(20)")] + [InlineData(nameof(FacetRow.Status), "character varying(12)")] + [InlineData(nameof(FacetRow.Tier), "character varying(7)")] + public void A_configured_column_facet_of_a_closed_set_is_kept_on_PostgreSQL(string property, string storeType) + { + using var db = new FacetDb(new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none").UseTypeKit().Options); + var column = db.Model.FindEntityType(typeof(FacetRow))!.FindProperty(property)!; + + column.GetRelationalTypeMapping().StoreType.ShouldBe(storeType); + column.GetColumnType().ShouldBe(storeType); + column.GetTypeMapping().Converter.ShouldNotBeNull().ProviderClrType.ShouldBe(typeof(string)); + } + + public sealed class FacetRow + { + public ProbeLevel Id { get; set; } = ProbeLevel.Low; + + public ProbeStatus Status { get; set; } = ProbeStatus.Open; + + public ProbeTier Tier { get; set; } = ProbeTier.Basic; + } + + /// A maximum length on a key and on a property, and a column type. + public sealed class FacetDb(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var row = modelBuilder.Entity(); + row.Property(r => r.Id).HasMaxLength(20); + row.Property(r => r.Status).HasMaxLength(12); + row.Property(r => r.Tier).HasColumnType("character varying(7)"); + } + } + public sealed class Delivery { public ProbeStatus Status { get; set; } = ProbeStatus.Open; From 373787431071ef46896a6b72d5cd018748df2594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:02:37 +0200 Subject: [PATCH 103/147] docs: UseTypeKit() says value objects are recognised by the generated attribute, not the interface Run-time discovery reads GeneratedValueObjectAttribute and GeneratedClosedSetAttribute (decision 17); the remarks named IValueObject and left closed sets out, and said it adds only plugins where it also adds conventions and an interceptor. Co-Authored-By: Claude Opus 5.5 --- .../TypeKitDbContextOptionsBuilderExtensions.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs index f421410..a8bf79c 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitDbContextOptionsBuilderExtensions.cs @@ -14,14 +14,18 @@ public static class TypeKitDbContextOptionsBuilderExtensions /// /// /// - /// Value objects are recognised by wherever EF meets - /// them: properties, keys, foreign keys, elements of primitive collections and query parameters. - /// Nothing is registered per type. Reading a column back does not apply the value object's rules + /// Value objects and closed sets are recognised wherever EF meets them, by the attribute the + /// generators put on each (, + /// whose type arguments are constrained to , + /// and ): properties, + /// keys, foreign keys, elements of primitive collections and query parameters. Never by name, and + /// never by listing a type's interfaces, which trimming can empty. Nothing is registered per type. + /// Reading a column back does not apply the value object's rules /// (). /// /// - /// It only adds plugins, so it replaces nothing of EF's or of another library's, and the order - /// against the database provider does not matter. + /// It only adds plugins, conventions and an interceptor, so it replaces nothing of EF's or of + /// another library's, and the order against the database provider does not matter. /// /// /// The options being configured. From deb6b7141605e47c9b22b005328351ecc8de14cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:10:56 +0200 Subject: [PATCH 104/147] test: a key over sbyte, ushort or uint is numbered by PostgreSQL's identity, and the docs say where none exists The 2.0.0 claim, measured on PostgreSQL 18: keys over sbyte, ushort and uint get an identity (smallint, integer, bigint) and are numbered 1, 2, as their plain keys are; a migration from a 1.5.1 model adds it with AlterColumn. A ulong key is numeric(20,0) and a char key character(1), neither of which can be an identity, for a plain key as for a value object; the README, the docs page and the design note now say so, and record the open difference in EF's choice of value generator for a default ulong or char key. Co-Authored-By: Claude Opus 5.5 --- design/ef-core.md | 19 ++++++++++--- docs/ef-core.md | 5 +++- .../README.md | 6 ++++- .../KeyGenerationTests.cs | 12 +++++++++ .../PostgreSqlRoundTripTests.cs | 27 +++++++++++++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/design/ef-core.md b/design/ef-core.md index b715725..0d4a0f8 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -77,9 +77,22 @@ the key is mapped to, as the providers' own conventions read it: an abstract TPC root has no table of its own, and reading the root's table took the generation away (2.0.0's review: on PostgreSQL every entity was stored with 0). The model tests build each shape for SQLite, Npgsql and SQL - Server, an abstract TPC root included. A key over a `Guid` is left alone: the application - assigns `OrderId.New()`, and a generated key that is already set makes EF take a new entity reached - through a navigation for an existing one. + Server, an abstract TPC root included. Where the provider has no identity for the type, a plain key + is not generated either, and neither is the value object's (measured on PostgreSQL 18: a `ulong` is + `numeric(20,0)`, strategy none; a `char` is `character(1)`, whose identity PostgreSQL refuses with + 22023 at creation and in the `AlterColumn` a 1.5.1 snapshot produces). A key over a `Guid` is left + alone: the application assigns `OrderId.New()`, and a generated key that is already set makes EF + take a new entity reached through a navigation for an existing one. + - **Open: the value generator is chosen by the column's type (measured 2026-10-13).** EF picks a + key's generator by its CLR type, else by its converter's provider type, which for a value object + is the column's: where the provider stores the wrapped type through a converter of its own, a + default key gets another generator than a plain key. A default `ulong` key throws + `OverflowException` on `Add` on SQL Server and PostgreSQL (EF's temporary `decimal` is negative), + where a plain one gets a temporary value; a default `char` key on SQL Server gets a permanent + character from a GUID string, where a plain one fails at insert. An assigned key is unaffected. + (The models and `Add` measured on all three providers, the inserts on PostgreSQL 18; SQL Server's + inserts follow from its models, unrun.) A fix needs a value generator per key that a compiled + model can write and that leaves HiLo alone. - **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` (`Properties()`) matches the property's CLR type; the README says to configure the value object (`Properties()`). Measured 2026-09-28 for precision, maximum length, column type and diff --git a/docs/ef-core.md b/docs/ef-core.md index eccd665..d3a6b4a 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -93,7 +93,10 @@ value object's type, since EF applies `ConfigureConventions` by CLR type. A single-column key over an integer is generated on add, as a key of that integer type is: an identity column on PostgreSQL and SQL Server, so switching an `int` key to a value object there -changes no schema. A key over a `Guid` is the application's to assign, `Id = OrderId.New()`, a version 7 +changes no schema. Where the provider has no identity for the type, a plain key is not generated +either: on PostgreSQL a `ulong` key is a `numeric(20,0)` and a `char` key a `character(1)`, neither of +which can be an identity, so configure `ValueGeneratedNever()` on such a key and assign it. A key over +a `Guid` is the application's to assign, `Id = OrderId.New()`, a version 7 `Guid` that sorts by creation time; for EF to generate it, configure `ValueGeneratedOnAdd()`. The root of a TPH or TPT hierarchy is generated too; under TPC, as for an `int` key, only where the provider has a sequence for the hierarchy (PostgreSQL, SQL Server), not on SQLite. A composite diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 6ffa203..612f5fc 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -70,7 +70,11 @@ modelBuilder.Entity().Property(o => o.Code).HasConversionThe keys over the integer types PostgreSQL has an identity column for, beyond int and long. +public sealed class PostgreSqlIntegerKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + } +} + public class SharedKeyPrincipal { public ShipmentNo Id { get; set; } } public class SharedKeyDependent { public ShipmentNo Id { get; set; } } diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs index cbaeef5..01f2659 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs @@ -124,6 +124,33 @@ public async Task An_integer_key_is_an_identity_column() second.Id.ShouldNotBe(first.Id); } + /// + /// A key over each integer type PostgreSQL has an identity column for, added twice: sbyte as + /// smallint, ushort as integer, uint as bigint, as their own keys are. + /// Not ulong, a numeric(20,0) that cannot be an identity, nor char, a + /// character(1) PostgreSQL refuses one on, either of them for a plain key too. + /// + [Fact] + public async Task A_key_over_each_integer_type_with_an_identity_is_numbered_by_the_database() + { + var cancellation = TestContext.Current.CancellationToken; + + await using var db = new PostgreSqlIntegerKeysDb(database.Options("typekit_keys")); + await db.Database.EnsureCreatedAsync(cancellation); + + KeyedByInt16[] shorts = [new(), new()]; + KeyedBySByte[] sbytes = [new(), new()]; + KeyedByUInt16[] ushorts = [new(), new()]; + KeyedByUInt32[] uints = [new(), new()]; + db.AddRange([.. shorts, .. sbytes, .. ushorts, .. uints]); + await db.SaveChangesAsync(cancellation); + + shorts.Select(e => e.Id.Value).ShouldBe([(short)1, (short)2], ignoreOrder: true); + sbytes.Select(e => e.Id.Value).ShouldBe([(sbyte)1, (sbyte)2], ignoreOrder: true); + ushorts.Select(e => e.Id.Value).ShouldBe([(ushort)1, (ushort)2], ignoreOrder: true); + uints.Select(e => e.Id.Value).ShouldBe([1u, 2u], ignoreOrder: true); + } + /// /// An abstract TPC root's key is numbered from the hierarchy's sequence, across its derived types' /// tables, as a long key is. Without the generation the first car was stored with 0 and the From 8f6af8b9604f043b968dd4ed8422ac3c70addc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:13:54 +0200 Subject: [PATCH 105/147] fix: ordering by a hand-written CompareTo through a key cast to object is refused Found by the weaving review: OrderBy(r => (object)r.Rank) and ThenBy of the same ordered by CompareTo in memory, 3, 2, 1, while EF dropped the boxing and ordered by the column, 1, 2, 3, without a word, since the interceptor read only the key's static type. It now also reads the selector's body under boxing and reference conversions; a conversion operator, which changes the value, is left as it is. A cast to IComparable EF refuses itself. Co-Authored-By: Claude Opus 5.5 --- .../HandWrittenOrderingInterceptor.cs | 17 +++++++++++++++++ .../HandWrittenOrderingTests.cs | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index f73c2cb..5f1499f 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -108,11 +108,28 @@ protected override Expression VisitMethodCall(MethodCallExpression node) else if ((method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable)) && OrderedType(method) is { } ordered) { Refuse(ordered, $"ordered by {method.Name}"); + + // A key the selector converts (to object, IComparable) is still ordered in memory by the + // value object's CompareTo, while EF drops the conversion and orders by the column. + if (KeySelector(node) is { } key) Refuse(Unconverted(key.Body).Type, $"ordered by {method.Name}"); } return base.VisitMethodCall(node); } + /// The operator's key or value selector, quoted for Queryable, a lambda for Enumerable. + private static LambdaExpression? KeySelector(MethodCallExpression node) => + node.Arguments.Count > 1 ? StripQuotes(node.Arguments[1]) as LambdaExpression : null; + + private static Expression StripQuotes(Expression expression) => + expression is UnaryExpression { NodeType: ExpressionType.Quote } quote ? StripQuotes(quote.Operand) : expression; + + /// The expression under its boxing and reference conversions; a conversion operator, which changes the value, is kept. + private static Expression Unconverted(Expression expression) => + expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.TypeAs, Method: null } conversion + ? Unconverted(conversion.Operand) + : expression; + /// /// Whether EF evaluates the comparison before translating, in a filter: it reads no parameter /// bound outside it (the entity) and no query (a DbSet of the context). diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index 979d308..d5cad7a 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -38,6 +38,10 @@ public sealed class HandWrittenOrderingTests : IDisposable // the second 3 where memory answers 1. ["OrderBy over a navigation"] = db => db.Parents.Select(p => p.Children.OrderBy(c => c.Rank).Select(c => c.Rank.Value).First()).ToList(), ["Max over a navigation"] = db => db.Parents.Select(p => p.Children.Max(c => c.Rank)).ToList(), + + // Boxed, the key still orders by CompareTo in memory, 3, 2, 1; EF dropped the cast and answered 1, 2, 3. + ["OrderBy a key cast to object"] = db => db.Ranked.OrderBy(r => (object)r.Rank).ToList(), + ["ThenBy a key cast to object"] = db => db.Ranked.OrderBy(r => r.Id % 1).ThenBy(r => (object)r.Rank).ToList(), }; private readonly SqliteConnection _connection = new("Data Source=:memory:"); From d559dbd5765d590b7a9def723725f6ce2df94c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:15:24 +0200 Subject: [PATCH 106/147] docs: what a migration from a 1.5.1 model does to a key over sbyte, ushort, uint or ulong Measured with the migrations differ from a model whose key is not generated: PostgreSQL gets ALTER COLUMN ... ADD GENERATED BY DEFAULT AS IDENTITY for sbyte, ushort and uint, and SQL Server's generator refuses the AlterColumn for those and ulong, as for any key whose identity changes. Co-Authored-By: Claude Opus 5.5 --- design/ef-core.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/design/ef-core.md b/design/ef-core.md index 0d4a0f8..ebbac86 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -80,7 +80,10 @@ Server, an abstract TPC root included. Where the provider has no identity for the type, a plain key is not generated either, and neither is the value object's (measured on PostgreSQL 18: a `ulong` is `numeric(20,0)`, strategy none; a `char` is `character(1)`, whose identity PostgreSQL refuses with - 22023 at creation and in the `AlterColumn` a 1.5.1 snapshot produces). A key over a `Guid` is left + 22023 at creation and in the `AlterColumn` a 1.5.1 snapshot produces). That `AlterColumn` adds the + identity to a key over `sbyte`, `ushort` or `uint` on PostgreSQL; SQL Server's migrations generator + refuses it for those and `ulong` ("To change the IDENTITY property of a column, the column needs to + be dropped and recreated"), as for any key whose identity changes. A key over a `Guid` is left alone: the application assigns `OrderId.New()`, and a generated key that is already set makes EF take a new entity reached through a navigation for an existing one. - **Open: the value generator is chosen by the column's type (measured 2026-10-13).** EF picks a From a35f317cc62e0143ea8a6eddc1a4a86f63d1725c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:20:37 +0200 Subject: [PATCH 107/147] docs: AGENTS.md says when to use the code graph, what it measurably misses here, and how to refresh it The graph leads and the source decides: measured on this repository, it has no node for a C# 14 extension-block member or anything Metalama generates, misses references that are not calls, guesses callers of generic instance members, and does not follow the working tree. .codebase-memory/ is ignored. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +++ AGENTS.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/.gitignore b/.gitignore index efac126..1ac58d1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Benchmark runs: the numbers worth keeping go into benchmarks/README.md BenchmarkDotNet.Artifacts/ + +# The codebase-memory MCP server's local graph artifact: rebuilt per checkout, never committed +.codebase-memory/ diff --git a/AGENTS.md b/AGENTS.md index 1cd3547..e6cca90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,45 @@ The SDK is pinned in `global.json` (10.0.4xx band, `latestPatch`). Tests run on Platform (xunit.v3 4.x), so `dotnet test` takes `--solution`/`--project`, and TRX output is `--report-xunit-trx`. The build treats warnings as errors, including CS1591 on shipping projects. +## Finding code + +The codebase-memory MCP server keeps a code graph of this repository, the project +`CodoMetis.TypeKit`. It is the fastest way into the code, and it misses whole kinds of reference +here, so it leads and the source decides. + +- **The graph first** to locate a symbol, list a type's implementers, trace callers and callees, and + size a change's impact (`search_graph`, `trace_path`, `query_graph`, `get_code_snippet`). +- **Then the source and its tests**, read before concluding or editing. An edge is a lead. +- **Text search** (`git grep`, `search_code`) for literals, messages, diagnostic ids, + configuration, documentation, and what the generators emit. +- **An empty result proves nothing.** Fall back to text search when the graph is unavailable, stale + or silent, and never conclude "unused", "no other caller" or "safe to change" from it alone. + Measured on 2026-10-13, the graph: + - finds types, interfaces, enums and ordinary methods, a type's implementers through generic + interfaces (84 of the Samples' 88 value objects), and the callers of an ordinary method across + files; + - has no node for a member of a C# 14 `extension(...)` block, which holds most of the `Option` and + `Result` extensions (`Choose`, `FirstOrNone`, `Transpose`, `Sequence`, the continuations of a + `Task>`) and `AsText`, and puts the calls inside one on the file; + - has nothing Metalama generates: none of the tests' 222 `.From(` calls has an edge, nor has a call + to `TryFrom`, `FromKnownGood`, `Parse` or a converter, and a template an aspect introduces by + `nameof` looks unreferenced. Read woven code as the Generators README's "Reading the generated + code" says; + - misses a reference that is not a call: a DI registration, a type argument, `typeof`, an attribute; + - resolves a call on a generic instance poorly (`Option.Match` has one resolved caller, where + the repository writes `.Match(` about 200 times across its types) and guesses wrong callers: a low-confidence `suffix_match` edge, or a same-named method of another API + (Metalama's `Diagnostics.Report` listed as calling `GeneratedCode.Report`). Overloads share one + node. +- **Refresh it**, since it does not follow the working tree: `index_repository` with the repository + root as `repo_path` and `name: "CodoMetis.TypeKit"`, at the start of a session and after a branch + switch, a rebase, a merge or a pull (about 6 s). Without `name` it starts a second project under a + derived name. `index_status` reports the checkout's HEAD, not the commit the graph was built from, + so refresh rather than check. +- It indexes `spikes/` and leaves out `.claude/`, `bin` and `obj`, so an agent in a worktree under + `.claude/worktrees/` reads the main checkout's graph, not its own: index the worktree under a name + of its own and delete that project when done, or search the source. `.codebase-memory/`, the + tool's local artifact, is ignored by git. + ## Rules that are easy to break - **`CodoMetis.TypeKit` stays Metalama-free.** Nothing in the base package may reference From cc57d09cf3e5b698a57305a02fb4679de7c5d4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:24:30 +0200 Subject: [PATCH 108/147] feat: a group's or a controller's fault status applies where its fault type is answered A WithFaultStatus on a route group, or any builder that covers several endpoints, and a [FaultStatus] on a controller class now apply to the endpoints of that scope that answer with the fault type and leave the others alone, so a group or controller can hold a health check. The host refuses such a status only when no endpoint of its own scope answers with the fault type; an endpoint of another group or controller does not count, and a controller deriving from a base with the attribute is a scope of its own. A status on one endpoint or action stays strict. The check groups statuses by instance, which every endpoint of a declaration's scope carries (decision 61). Co-Authored-By: Claude Opus 5.5 --- ...tatus-applies-where-fault-type-answered.md | 47 ++ design/adr/README.md | 1 + docs/aspnet-core.md | 3 +- .../FaultResponsesStartupCheck.cs | 41 +- .../FaultStatus.cs | 37 +- src/CodoMetis.TypeKit.AspNetCore/README.md | 27 +- .../FaultStatusScopeTests.cs | 417 ++++++++++++++++++ .../AspNetCore/FaultResponseSamples.cs | 22 + 8 files changed, 576 insertions(+), 19 deletions(-) create mode 100644 design/adr/0061-scope-s-fault-status-applies-where-fault-type-answered.md create mode 100644 test/CodoMetis.TypeKit.AspNetCore.Tests/FaultStatusScopeTests.cs diff --git a/design/adr/0061-scope-s-fault-status-applies-where-fault-type-answered.md b/design/adr/0061-scope-s-fault-status-applies-where-fault-type-answered.md new file mode 100644 index 0000000..3ae73a7 --- /dev/null +++ b/design/adr/0061-scope-s-fault-status-applies-where-fault-type-answered.md @@ -0,0 +1,47 @@ +# 0061. A scope's fault status applies where its fault type is answered + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [decision 49](0049-fault-responses-client-can-act.md); +[OpenAPI](../openapi.md). + +Decision 49 gave an endpoint `WithFaultStatus` and an MVC action `[FaultStatus]`, and the +startup check refused a status on an endpoint that answers with no fault of its type. Both also reach +every endpoint of a scope: a route group's `WithFaultStatus`, and the attribute on a controller class. +A group or controller that held a health check beside its fault endpoints then did not start, since +the health check carried the status too (measured by the review). + +A status declared on one endpoint or action stays strict: that endpoint must answer with the fault +type, or the host does not start. A status declared on a scope applies to the endpoints of that scope +that answer with its fault type and leaves the others alone. A scope is a route group, any +convention builder that covers several endpoints (the one `MapControllers()` returns), or a +controller class; a controller that derives from a base class with the attribute is a scope of its +own. The host does not start when no endpoint of the status's own scope answers with the fault type. +The unknown-member and error-status refusals are unchanged, and so is the order: the last status in +ASP.NET's metadata wins, an endpoint's own over its group's, an inner group's over an outer group's, +an action's over its controller's, and `MapControllers()`'s builder over every attribute, since +ASP.NET puts its metadata last. + +Its own scope, not the host: the check exists for a status that misleads, set on the wrong group or +left behind when the endpoints that needed it moved. Satisfied by any endpoint of the host that +answers with the fault type, which some other group usually does, it would pass almost every such +status. + +A declaration is one instance: `WithFaultStatus` adds one object, which the builder's convention puts +on every endpoint of the scope, and MVC reads a controller's attributes once per controller and puts +the same instances on each of its actions (measured on ASP.NET Core 10.0.12: shared across a +controller's actions and an action's two routes, separate for two controllers deriving from one +base, stable across reads of the endpoints). The check groups statuses by instance, by reference, so +the scope comes from the endpoints that carry it; an endpoint's own status is a scope of one. +`FaultStatusScopeTests` holds each case. + +Rejected: +- **Strict everywhere**, the rule before: a group or a controller cannot hold a health check, a + version endpoint or anything else that reports no fault, and a host splits its routes by what they + return. +- **No check for a status a scope gave**: a stale status on a group is then silent, and silent is + what this package refuses. + +Not refused: a group with no endpoint carries its status nowhere, and a scope's status that each of +its matching endpoints replaces with its own is never used. diff --git a/design/adr/README.md b/design/adr/README.md index eec48c1..9ae3b34 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -66,4 +66,5 @@ per area ([design](../plan.md)) link to the records they rest on. | [0057](0057-hand-written-ordering-refused-in-query.md) | A query that orders by a hand-written `CompareTo` is refused | 2026-10-13 | accepted | | [0058](0058-integer-key-follows-ef-rule.md) | A value-object key is generated wherever EF generates its wrapped type's | 2026-10-13 | accepted | | [0059](0059-text-guard-refuses-what-it-cannot-normalize.md) | The text guards refuse what they cannot normalize, and `AsText()` throws where nothing composes | 2026-10-13 | accepted | +| [0061](0061-scope-s-fault-status-applies-where-fault-type-answered.md) | A scope's fault status applies where its fault type is answered | 2026-10-13 | accepted | | [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md index 08112ee..915e063 100644 --- a/docs/aspnet-core.md +++ b/docs/aspnet-core.md @@ -157,7 +157,8 @@ builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = A response can carry more, each part opt-in, so the default above stays as it is: a `type` and a `title` per fault, a closed set as the fault type, answered by its members' kinds and descriptions, -a sentence per occurrence in a `Fault`, a status per endpoint, the endpoint's own success +a sentence per occurrence in a `Fault`, a status per endpoint, route group or controller +(applied to those of their endpoints that answer with the fault type), the endpoint's own success such as a `201 Created`, and the fault on the request for a `CustomizeProblemDetails`. The README's [Mapping faults to statuses](../src/CodoMetis.TypeKit.AspNetCore/README.md#mapping-faults-to-statuses) and the sections after it show each. diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs index 8f72b61..79e41e4 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponsesStartupCheck.cs @@ -7,18 +7,32 @@ namespace CodoMetis.TypeKit.AspNetCore; /// -/// Refuses to start a host with an endpoint whose fault type has no mapping, or whose -/// WithFaultStatus names a fault type it does not answer with or a member its type does not -/// have, naming each endpoint. +/// Refuses to start a host with an endpoint whose fault type has no mapping, a fault status whose +/// scope has no endpoint that answers with its fault type, or a fault status naming a member its type +/// does not have, naming each endpoint. /// /// +/// /// A startup filter, because the endpoints are known only once the pipeline is built: a hosted service /// starts before that and sees none (spikes/ResultHttp, finding 8). Its exception fails the host's /// start before the server listens. Without it a missing mapping shows only when that fault first /// occurs, in production (finding 7). +/// +/// +/// A status's scope is where it was declared (decision 61): one endpoint or action, which must answer +/// with the fault type; or a route group, a convention builder or a controller, of which one endpoint +/// must. Each declaration is one instance that every endpoint of its scope carries in its metadata, so +/// the scope is read from the endpoints that hold that instance. +/// /// internal sealed class FaultResponsesStartupCheck : IStartupFilter { + /// + /// One declaration is one instance: an attribute's own Equals compares its values, so two + /// controllers' equal attributes would read as one scope, and it reads them by reflection. + /// + private static readonly IEqualityComparer DeclarationComparer = ReferenceEqualityComparer.Instance; + public Action Configure(Action next) => app => { next(app); @@ -35,11 +49,15 @@ public Action Configure(Action next) = $"No HTTP status is mapped for the fault type {fault.First().FaultName}, which these endpoints answer with: " + $"{string.Join(", ", fault.Select(pair => pair.Endpoint).Distinct())}. Map each of its members with services.AddFaultResponses<{fault.First().FaultName}>(fault => …)."); - // A status for a fault type the endpoint never answers with is stale, or set on the wrong endpoint. + // A status is one instance, which every endpoint of the scope it was declared on carries: a group's + // or a convention builder's endpoints, a controller's actions, or the one endpoint or action. It + // concerns the endpoints of its scope that answer with its fault type, and is stale, or set on the + // wrong scope, where none does. An endpoint of another scope does not count: it answers to its own. var stray = endpoints.Endpoints - .SelectMany(endpoint => endpoint.Metadata.OfType() - .Where(status => !endpoint.Metadata.OfType().Any(answered => answered.FaultType == status.FaultType)) - .Select(status => $"{status.Describe()} is set on {Name(endpoint)}, which answers with no {FaultTypes.NameOf(status.FaultType)}.")); + .SelectMany(endpoint => endpoint.Metadata.OfType().Select(status => (Status: status, Endpoint: endpoint))) + .GroupBy(pair => pair.Status, pair => pair.Endpoint, DeclarationComparer) + .Where(scope => !scope.Any(endpoint => Answers(endpoint, scope.Key.FaultType))) + .Select(scope => Stray(scope.Key, [.. scope.Distinct()])); var nameless = endpoints.Endpoints .SelectMany(endpoint => endpoint.Metadata.OfType().Distinct() @@ -52,5 +70,14 @@ public Action Configure(Action next) = throw new InvalidOperationException(string.Join(" ", refusals) + FaultRefusals.Mapping); }; + private static bool Answers(Endpoint endpoint, Type faultType) => + endpoint.Metadata.OfType().Any(answered => answered.FaultType == faultType); + + private static string Stray(IFaultStatus status, IReadOnlyList scope) => + scope.Count == 1 + ? $"{status.Describe()} is set on {Name(scope[0])}, which answers with no {FaultTypes.NameOf(status.FaultType)}." + : $"{status.Describe()} is set on {scope.Count} endpoints, none of which answers with {FaultTypes.NameOf(status.FaultType)}: " + + $"{string.Join(", ", scope.Select(Name))}."; + private static string Name(Endpoint endpoint) => endpoint.DisplayName ?? endpoint.ToString() ?? "an endpoint"; } diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs index bc0ad48..2db8824 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultStatus.cs @@ -59,14 +59,24 @@ public static void ThrowIfNoErrorStatus(int status, string parameter) } /// - /// An MVC action's status for one fault, in place of the host's: [FaultStatus<OrderFault>(nameof(OrderFault.NameTaken), 422)]. - /// A minimal API endpoint says it with .WithFaultStatus(OrderFault.NameTaken, 422). + /// An MVC action's or controller's status for one fault, in place of the host's: + /// [FaultStatus<OrderFault>(nameof(OrderFault.NameTaken), 422)]. A minimal API endpoint or + /// route group says it with .WithFaultStatus(OrderFault.NameTaken, 422). /// /// + /// /// The member is named, since an attribute takes only constants, and nameof keeps the name /// in step with the member. A name no member has stops the host from starting, as an endpoint /// whose fault type is not mapped does. The response, the operation's document entry and the /// startup check all read it. + /// + /// + /// On an action, the action must answer with , or the host does not + /// start. On a controller class, it applies to the actions that answer with + /// and leaves the others alone; the host does not start only when none + /// of that controller's actions does. A controller that derives from a base class with the + /// attribute is a controller of its own. An action's own status replaces its controller's. + /// /// /// The fault type, an enum or a closed set. [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] @@ -105,17 +115,28 @@ public FaultStatusAttribute(string fault, int status) // Beside WithMetadata and the other endpoint conventions. namespace Microsoft.AspNetCore.Builder { - /// A status for one fault on one endpoint, in place of the host's. + /// A status for one fault on one endpoint, or on a group of them, in place of the host's. public static class TypeKitFaultStatusEndpointConventionBuilderExtensions { /// - /// Answers with on this endpoint, in place of the - /// status the host maps it to: the same fault can be a 404 on one route and a 409 on another. + /// Answers with on this endpoint, or on the + /// endpoints of this group that answer with , in place of the status + /// the host maps it to: the same fault can be a 404 on one route and a 409 on another. /// /// - /// The response, the operation's document entry and the startup check all read it. A status for - /// a fault type the endpoint does not answer with stops the host from starting, so a stale one is - /// found before it misleads. + /// + /// The response, the operation's document entry and the startup check all read it. On one + /// endpoint, a status for a fault type the endpoint does not answer with stops the host from + /// starting, so a stale one is found before it misleads. + /// + /// + /// On a route group, or any builder that covers several endpoints such as the one + /// MapControllers() returns, it applies to the endpoints that answer with + /// and leaves the others, a health check, alone. The host does not + /// start only when none of the endpoints of that group answers with ; + /// an endpoint of another group does not count. An endpoint's own status replaces its group's, + /// and an inner group's an outer group's. + /// /// /// The endpoint, or a group or convention of endpoints. /// The fault, an enum member or a closed set's member. diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index bc400e5..eb68dee 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -266,9 +266,30 @@ An MVC action says it with an attribute, naming the member, since an attribute t public Results, FaultProblem> Open(NewAccount body) => accounts.Open(body).ToHttpResult(); ``` -On a controller class, the attribute applies to every action of it. A status outside 400 to 599 is -refused where it is set. A status for a fault type the endpoint does not answer with, or an attribute -naming no member, stops the host from starting, naming the endpoint. +A route group says it for its endpoints, and a controller class, with the same attribute, for its +actions. Such a status applies to the endpoints of that group or controller that answer with the +fault type and leaves the others alone, so a group can hold a health check beside them: + +```csharp +var v3 = app.MapGroup("/v3").WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); +v3.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()); // answers with AccountFault: the 422 applies +v3.MapGet("/health", () => "ok"); // answers with none: left alone +``` + +What stops the host from starting, naming the status and its endpoints: + +- a status on one endpoint or action that answers with no fault of that type; +- a status on a group or a controller none of whose own endpoints answers with that type. An endpoint + of another group or controller does not count, and a controller that derives from a base class + with the attribute is a controller of its own; +- an attribute naming no member. + +The same holds for any convention builder that covers several endpoints, such as the one +`MapControllers()` returns. A status outside 400 to 599 is refused where it is set. Where several +statuses name one member, the last in ASP.NET's metadata order wins: an endpoint's own over its +group's, an inner group's over an outer group's, an action's over its controller's. ASP.NET puts the +metadata of `MapControllers()`'s builder after every attribute, so a status set there wins over an +action's own. ### The success the endpoint chooses diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultStatusScopeTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultStatusScopeTests.cs new file mode 100644 index 0000000..bff8945 --- /dev/null +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultStatusScopeTests.cs @@ -0,0 +1,417 @@ +using System.Reflection; +using CodoMetis.TypeKit.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; + +namespace CodoMetis.TypeKit.AspNetCore.Tests; + +/// +/// A fault status declared on a scope, a route group or a controller, applies to the endpoints of that +/// scope that answer with its fault type and leaves the others alone; the host refuses it only where its +/// own scope has no such endpoint, whatever other scopes have (decision 61). A status on one endpoint or +/// action is a scope of one, and stays strict. +/// +public sealed class FaultStatusScopeTests +{ + public enum OrderFault { NotFound, NameTaken } + + private static Result Find(int id) => id switch + { + 1 => 1, + 2 => Result.Error(OrderFault.NameTaken), + _ => Result.Error(OrderFault.NotFound), + }; + + public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; + + // Mixed scopes: the fault endpoint takes the scope's status, the plain one answers and is documented as before. + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_group_s_status_applies_to_its_endpoints_that_answer_with_the_fault_type_and_leaves_the_others(OpenApiSpecVersion version) + { + await using var app = await StartAsync(version, endpoints: app => + { + var orders = app.MapGroup("/orders").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + orders.MapGet("/health", () => "ok"); + }); + + await ProblemAsync(app, "/orders/9", HttpStatusCode.Gone); + (await app.GetTestClient().GetStringAsync("/orders/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var document = await DocumentAsync(app); + Statuses(document, "/orders/{id}").ShouldBe(["200", "409", "410"]); + Statuses(document, "/orders/health").ShouldBe(["200"]); + } + + [Theory] + [MemberData(nameof(Versions))] + public async Task A_controller_s_status_applies_to_its_actions_that_answer_with_the_fault_type_and_leaves_the_others(OpenApiSpecVersion version) + { + await using var app = await StartAsync(version, mvc: [typeof(MixedController)]); + + await ProblemAsync(app, "/mvc/orders/9", HttpStatusCode.Gone); + (await app.GetTestClient().GetStringAsync("/mvc/orders/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var document = await DocumentAsync(app); + Statuses(document, "/mvc/orders/{id}").ShouldBe(["200", "409", "410"]); + Statuses(document, "/mvc/orders/health").ShouldBe(["200"]); + } + + // Wholly stale scopes: no endpoint of the scope answers with the fault type. + + [Fact] + public async Task A_group_none_of_whose_endpoints_answers_with_the_fault_type_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var probes = app.MapGroup("/probes").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + probes.MapGet("/health", () => "ok"); + probes.MapGet("/ready", () => "ok"); + })); + + refusal.Message.ShouldContain( + "WithFaultStatus(FaultStatusScopeTests.OrderFault.NotFound, 410) is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: " + + "HTTP: GET /probes/health, HTTP: GET /probes/ready."); + } + + [Fact] + public async Task A_controller_none_of_whose_actions_answers_with_the_fault_type_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(StaleController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NotFound\", 410)] is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: "); + refusal.Message.ShouldContain("StaleController.Health"); + refusal.Message.ShouldContain("StaleController.Ready"); + } + + // Unrelated scopes: an endpoint of another scope that answers with the fault type does not save a stale declaration. + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_stale_group_stops_the_host_beside_another_group_that_answers_with_the_fault_type(bool otherDeclaresItsOwn) + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var probes = app.MapGroup("/probes").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + probes.MapGet("/health", () => "ok"); + probes.MapGet("/ready", () => "ok"); + + var orders = app.MapGroup("/orders"); + if (otherDeclaresItsOwn) orders.WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + })); + + refusal.Message.ShouldContain( + "WithFaultStatus(FaultStatusScopeTests.OrderFault.NotFound, 410) is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: " + + "HTTP: GET /probes/health, HTTP: GET /probes/ready."); + refusal.Message.ShouldNotContain("GET /orders"); + } + + [Fact] + public async Task A_stale_controller_stops_the_host_beside_another_controller_that_answers_with_the_fault_type() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(StaleController), typeof(MixedController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NotFound\", 410)] is set on 2 endpoints, none of which answers with FaultStatusScopeTests.OrderFault: "); + refusal.Message.ShouldContain("StaleController.Health"); + refusal.Message.ShouldNotContain("MixedController"); + } + + /// An attribute on a base controller is declared again on each controller that derives from it: each is its own scope. + [Fact] + public async Task Each_controller_deriving_from_a_base_with_a_status_is_a_scope_of_its_own() + { + await using (var app = await StartAsync(mvc: [typeof(ArchivedOrdersController)])) + await ProblemAsync(app, "/mvc/archive/orders/9", HttpStatusCode.Gone); + + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(ArchivedOrdersController), typeof(ArchiveHealthController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NotFound\", 410)] is set on "); + refusal.Message.ShouldContain("ArchiveHealthController.Health"); + refusal.Message.ShouldContain("which answers with no FaultStatusScopeTests.OrderFault."); + refusal.Message.ShouldNotContain("ArchivedOrdersController"); + } + + // Nested scopes: the innermost status wins, and an outer declaration is satisfied by an endpoint of an inner group. + + [Theory] + [MemberData(nameof(Versions))] + public async Task Nested_groups_and_an_endpoint_answer_with_the_innermost_status(OpenApiSpecVersion version) + { + await using var app = await StartAsync(version, endpoints: app => + { + // The outer group's only endpoints that answer with the fault type are the inner group's. + var outer = app.MapGroup("/outer") + .WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone) + .WithFaultStatus(OrderFault.NameTaken, StatusCodes.Status422UnprocessableEntity); + outer.MapGet("/health", () => "ok"); + + var inner = outer.MapGroup("/inner").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status451UnavailableForLegalReasons); + inner.MapGet("/a/{id:int}", (int id) => Find(id).ToHttpResult()); + inner.MapGet("/b/{id:int}", (int id) => Find(id).ToHttpResult()).WithFaultStatus(OrderFault.NotFound, StatusCodes.Status404NotFound); + }); + + await ProblemAsync(app, "/outer/inner/a/9", HttpStatusCode.UnavailableForLegalReasons); + await ProblemAsync(app, "/outer/inner/a/2", HttpStatusCode.UnprocessableEntity); + await ProblemAsync(app, "/outer/inner/b/9", HttpStatusCode.NotFound); + await ProblemAsync(app, "/outer/inner/b/2", HttpStatusCode.UnprocessableEntity); + (await app.GetTestClient().GetStringAsync("/outer/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var document = await DocumentAsync(app); + Statuses(document, "/outer/inner/a/{id}").ShouldBe(["200", "422", "451"]); + Statuses(document, "/outer/inner/b/{id}").ShouldBe(["200", "404", "422"]); + Statuses(document, "/outer/health").ShouldBe(["200"]); + } + + /// + /// The builder MapControllers() returns covers every action, and ASP.NET puts its metadata after + /// the controllers' attributes: its status is the last, and wins over an action's own. + /// + [Fact] + public async Task A_status_on_MapControllers_covers_every_action_and_comes_after_their_attributes() + { + await using var app = await StartAsync( + mvc: [typeof(StrictActionController), typeof(PlainController)], + controllers: builder => builder.WithFaultStatus(OrderFault.NotFound, StatusCodes.Status451UnavailableForLegalReasons)); + + await ProblemAsync(app, "/mvc/strict/9", HttpStatusCode.UnavailableForLegalReasons); + (await app.GetTestClient().GetStringAsync("/mvc/plain", TestContext.Current.CancellationToken)).ShouldBe("ok"); + } + + // A status on one endpoint or action stays strict, also inside a scope whose own status is satisfied. + + [Fact] + public async Task An_endpoint_s_own_status_on_a_plain_endpoint_stops_the_host_inside_a_satisfied_group() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var orders = app.MapGroup("/orders").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + orders.MapGet("/health", () => "ok").WithFaultStatus(OrderFault.NameTaken, StatusCodes.Status422UnprocessableEntity); + })); + + refusal.Message.ShouldContain("WithFaultStatus(FaultStatusScopeTests.OrderFault.NameTaken, 422) is set on HTTP: GET /orders/health, which answers with no FaultStatusScopeTests.OrderFault."); + refusal.Message.ShouldNotContain("NotFound, 410"); + } + + [Fact] + public async Task An_action_s_own_status_on_a_plain_action_stops_the_host_inside_a_satisfied_controller() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(StrayActionController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"NameTaken\", 422)] is set on "); + refusal.Message.ShouldContain("StrayActionController.Health"); + refusal.Message.ShouldContain("which answers with no FaultStatusScopeTests.OrderFault."); + refusal.Message.ShouldNotContain("\"NotFound\", 410"); + } + + // A scope's declaration is checked as an endpoint's is: a member its type has, and an error status. + + [Fact] + public async Task A_group_s_status_for_a_value_no_member_has_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(endpoints: app => + { + var orders = app.MapGroup("/orders").WithFaultStatus((OrderFault)42, StatusCodes.Status410Gone); + orders.MapGet("/{id:int}", (int id) => Find(id).ToHttpResult()); + orders.MapGet("/health", () => "ok"); + })); + + refusal.Message.ShouldContain("WithFaultStatus(FaultStatusScopeTests.OrderFault.42, 410) names a value no member of FaultStatusScopeTests.OrderFault has, on HTTP: GET /orders/{id:int}."); + } + + [Fact] + public async Task A_controller_s_attribute_naming_no_member_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(MisnamedController)])); + + refusal.Message.ShouldContain("[FaultStatus(\"Gone\", 410)] names no member of FaultStatusScopeTests.OrderFault, on "); + refusal.Message.ShouldContain("MisnamedController.Get"); + } + + [Fact] + public void A_group_s_status_is_an_error_status() => + Should.Throw(() => WebApplication.CreateBuilder().Build().MapGroup("/orders").WithFaultStatus(OrderFault.NotFound, StatusCodes.Status200OK)) + .ParamName.ShouldBe("status"); + + [Fact] + public async Task A_controller_s_attribute_with_no_error_status_stops_the_host() + { + var refusal = await Should.ThrowAsync(() => StartAsync(mvc: [typeof(NoErrorController)])); + + var cause = refusal; + while (cause is not ArgumentOutOfRangeException && cause.InnerException is { } inner) cause = inner; + cause.ShouldBeOfType($"{refusal.GetType().Name}: {refusal.Message}").ParamName.ShouldBe("status"); + } + + private static async Task StartAsync( + OpenApiSpecVersion version = OpenApiSpecVersion.OpenApi3_1, + Action? endpoints = null, + Type[]? mvc = null, + Action? controllers = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => + { + options.OpenApiVersion = version; + options.AddTypeKit(); + }); + builder.Services.AddFaultResponses(fault => fault switch + { + OrderFault.NotFound => StatusCodes.Status404NotFound, + OrderFault.NameTaken => StatusCodes.Status409Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + if (mvc is not null) + { + builder.Services.AddControllers().ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new OnlyControllers(mvc)); + }); + } + + var app = builder.Build(); + app.MapOpenApi(); + endpoints?.Invoke(app); + if (mvc is not null) + { + var actions = app.MapControllers(); + controllers?.Invoke(actions); + } + + try + { + await app.StartAsync(TestContext.Current.CancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return app; + } + + private static async Task ProblemAsync(WebApplication app, string path, HttpStatusCode status) + { + using var response = await app.GetTestClient().GetAsync(path, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(status, path); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json"); + } + + private static async Task DocumentAsync(WebApplication app) => + JsonNode.Parse(await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!; + + private static string[] Statuses(JsonNode document, string path) => + [.. document["paths"]![path]!["get"]!["responses"]!.AsObject().Select(response => response.Key).Order()]; + + private sealed class OnlyControllers(Type[] controllers) : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) + { + foreach (var controller in controllers) feature.Controllers.Add(controller.GetTypeInfo()); + } + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class MixedController : ControllerBase + { + [HttpGet("/mvc/orders/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + + [HttpGet("/mvc/orders/health")] + public string Health() => "ok"; + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class StaleController : ControllerBase + { + [HttpGet("/mvc/stale/health")] + public string Health() => "ok"; + + [HttpGet("/mvc/stale/ready")] + public string Ready() => "ok"; + } + + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public abstract class ArchiveControllerBase : ControllerBase; + + [ApiController] + public sealed class ArchivedOrdersController : ArchiveControllerBase + { + [HttpGet("/mvc/archive/orders/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + } + + [ApiController] + public sealed class ArchiveHealthController : ArchiveControllerBase + { + [HttpGet("/mvc/archive/health")] + public string Health() => "ok"; + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public sealed class StrayActionController : ControllerBase + { + [HttpGet("/mvc/stray/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + + [HttpGet("/mvc/stray/health")] + [FaultStatus(nameof(OrderFault.NameTaken), StatusCodes.Status422UnprocessableEntity)] + public string Health() => "ok"; + } + + [ApiController] + public sealed class StrictActionController : ControllerBase + { + [HttpGet("/mvc/strict/{id:int}")] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status410Gone)] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + } + + [ApiController] + public sealed class PlainController : ControllerBase + { + [HttpGet("/mvc/plain")] + public string Get() => "ok"; + } + + [ApiController] + [FaultStatus("Gone", StatusCodes.Status410Gone)] + public sealed class MisnamedController : ControllerBase + { + [HttpGet("/mvc/misnamed/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + + [HttpGet("/mvc/misnamed/health")] + public string Health() => "ok"; + } + + [ApiController] + [FaultStatus(nameof(OrderFault.NotFound), StatusCodes.Status200OK)] + public sealed class NoErrorController : ControllerBase + { + [HttpGet("/mvc/no-error/{id:int}")] + public Results, FaultProblem> Get(int id) => Find(id).ToHttpResult(); + } +} diff --git a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs index 478a3c4..d3b0bdc 100644 --- a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs @@ -67,6 +67,12 @@ public static void Endpoints(WebApplication app, IAccounts accounts) .WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); // end sample + // sample: CodoMetis.TypeKit.AspNetCore/fault-status-group + var v3 = app.MapGroup("/v3").WithFaultStatus(AccountFault.EmailTaken, StatusCodes.Status422UnprocessableEntity); + v3.MapPost("/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult()); // answers with AccountFault: the 422 applies + v3.MapGet("/health", () => "ok"); // answers with none: left alone + // end sample + // sample: CodoMetis.TypeKit.AspNetCore/fault-success app.MapPost("/v2/accounts", (NewAccount body) => accounts.Open(body).ToHttpResult(account => TypedResults.Created($"/accounts/{account.Id}", account))); // end sample @@ -151,6 +157,22 @@ public async Task A_fault_set_is_answered_by_kind_and_the_endpoint_s_status_wher problem["detail"]!.GetValue().ShouldBe("Another account uses that email address."); } + /// The README's group: its status applies to the endpoint that answers with the fault type, and leaves the health check alone. + [Fact] + public async Task A_group_s_status_applies_to_its_fault_endpoint_and_leaves_its_health_check_alone() + { + await using var app = await StartAsync(Result.Error(AccountFault.EmailTaken)); + var client = app.GetTestClient(); + + using var refused = await client.PostAsync("/v3/accounts", Body(), TestContext.Current.CancellationToken); + refused.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + (await client.GetStringAsync("/v3/health", TestContext.Current.CancellationToken)).ShouldBe("ok"); + + var paths = JsonNode.Parse(await client.GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken))!["paths"]!; + paths["/v3/accounts"]!["post"]!["responses"]!.AsObject().Select(response => response.Key).Order().ShouldBe(["200", "403", "404", "422"]); + paths["/v3/health"]!["get"]!["responses"]!.AsObject().Select(response => response.Key).ShouldBe(["200"]); + } + [Fact] public async Task The_endpoint_s_own_success_is_a_201_with_its_location() { From 50a4cff48c6c14ae97e7cf19d0c6fd6b4de27c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:25:59 +0200 Subject: [PATCH 109/147] docs: the changelog says where a group's or a controller's fault status applies Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0028d8..c8e5df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,13 +177,16 @@ against 1.5.1 meets them. set as the fault type, `AddFaultResponses()` answering every member by its kind (`NotFound` 404, `Conflict` 409, `Invalid` 422, `Forbidden` 403, `Unavailable` 503) with its description as the `detail`; a `FaultResponse` per member with a `type` and a `title`; a `Fault`'s detail as the - problem's `detail`; `WithFaultStatus(fault, status)` on an endpoint and `[FaultStatus]` on an - MVC action, for that endpoint's response and document entry; `ToHttpResult(success)`, the + problem's `detail`; `WithFaultStatus(fault, status)` on an endpoint or a route group and + `[FaultStatus]` on an MVC action or a controller, for the response and document entry of + each endpoint it covers that answers with the fault type, so a group or a controller leaves the + others, a health check, alone, and an endpoint's or action's own status replaces it; `ToHttpResult(success)`, the endpoint's own success such as `Created`; and `IFaultFeature` on the request while the problem is written, for the host's `CustomizeProblemDetails`, with `FaultResponseOptions.QualifiedFaultNames`, which names a fault after its type's nesting chain (`Billing.Fault.NotFound`). - The startup check refuses a status for a fault type the endpoint does not answer with, and an - attribute naming no member. + The startup check refuses a status on an endpoint or action that does not answer with its fault + type, a status on a group or controller none of whose own endpoints does, and an attribute naming + no member. ### Changed From 48db5e1f83dcec44bb390e4e107f92654f3b7ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:26:06 +0200 Subject: [PATCH 110/147] docs: the changelog says a query filter's hand-written ordering is refused when the model is built Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e5df0..f8e0465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -220,7 +220,9 @@ against 1.5.1 meets them. - **`CodoMetis.TypeKit.EntityFrameworkCore`**: a query that compares, orders, or takes `Min`/`Max` of a value object that declares its own `CompareTo` is refused, naming the value object. It was translated to the column's order, the opposite of the in-memory one for a reversed ordering; order - by `.Value` where the column's order is meant. + by `.Value` where the column's order is meant. The refusal covers an ordering over a navigation and + a key cast to `object`, and a query filter that does so is refused when the model is built, naming + the entity type and the filter. - **`CodoMetis.TypeKit.EntityFrameworkCore`**: a value-object key is generated on add wherever EF generates its wrapped type's: over `sbyte`, `uint`, `ulong`, `ushort` and `char` too, and at the root of a TPH hierarchy on SQLite, which stored 0. A TPC key on SQLite is not generated, as a plain From 787ad3031bc1bd75cd6533a55a79ab333e422432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:53:16 +0200 Subject: [PATCH 111/147] fix: a value object over a closed set of its own project is refused (CMTK1005) ClosedSetAspect runs last in AspectOrder, and the value-object aspects do not see what it introduces: over a set declared in the same project a value object was generated without Parse, TryParse, IParsable and its own type converter, while its JSON worked, and over the same set in a referenced project it had them. The fabric now refuses it with CMTK1005, naming the set and the two ways out (declare the set in a referenced project, or wrap its name), as it refuses a value object over a value object. Decision 60. Co-Authored-By: Claude Opus 5.5 --- ...ct-over-same-project-closed-set-refused.md | 19 +++++++++++ design/adr/README.md | 1 + design/aspects.md | 8 ++++- design/closed-sets.md | 6 ++++ docs/closed-sets.md | 5 +++ src/CodoMetis.TypeKit.Generators/README.md | 2 +- .../ValueObjectAspect.cs | 21 ++++++------ .../ValueObjectFabric.cs | 5 +-- .../ValueObjectTypes.cs | 32 +++++++++++++++++-- .../BuildOutcomeTests.cs | 26 +++++++++++++++ .../ClosedSetTests.cs | 26 +++++++++++++++ 11 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 design/adr/0060-value-object-over-same-project-closed-set-refused.md diff --git a/design/adr/0060-value-object-over-same-project-closed-set-refused.md b/design/adr/0060-value-object-over-same-project-closed-set-refused.md new file mode 100644 index 0000000..6929d24 --- /dev/null +++ b/design/adr/0060-value-object-over-same-project-closed-set-refused.md @@ -0,0 +1,19 @@ +# 0060. A value object over a closed set of its own project is refused (CMTK1005) + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [Aspects](../aspects.md), [Closed sets](../closed-sets.md). + +`ClosedSetAspect` runs last in `AspectOrder`, and the value-object aspects do not see what it +introduces. Over `sealed partial class SetA : IClosedSet` declared in the same project, +`readonly partial record struct OverSet : IValue` was generated without `Parse`, `TryParse` +and `IParsable`, and with the base `TypeConverter` rather than its own, while its JSON +worked (measured). Over the same set in a referenced project it got all of them. That is the +layout-dependent surface CMTK1005 already refuses for a value object over a value object of its own +project, and it is refused the same way and with the same id: the fabric answers it, since it reads +the wrapped type, and the message names the set and says to declare it in a referenced project or to +wrap its name. A set from a referenced project stays allowed and generated in full. A refusal can be +lifted later, by ordering the aspects so that the value-object aspects see the set's members; a +silent half-generation could not be taken back. 2.0.0 is unreleased, and closed sets are new in it, +so no 1.x declaration meets the refusal. diff --git a/design/adr/README.md b/design/adr/README.md index 9ae3b34..e5349ad 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -66,5 +66,6 @@ per area ([design](../plan.md)) link to the records they rest on. | [0057](0057-hand-written-ordering-refused-in-query.md) | A query that orders by a hand-written `CompareTo` is refused | 2026-10-13 | accepted | | [0058](0058-integer-key-follows-ef-rule.md) | A value-object key is generated wherever EF generates its wrapped type's | 2026-10-13 | accepted | | [0059](0059-text-guard-refuses-what-it-cannot-normalize.md) | The text guards refuse what they cannot normalize, and `AsText()` throws where nothing composes | 2026-10-13 | accepted | +| [0060](0060-value-object-over-same-project-closed-set-refused.md) | A value object over a closed set of its own project is refused (CMTK1005) | 2026-10-13 | accepted | | [0061](0061-scope-s-fault-status-applies-where-fault-type-answered.md) | A scope's fault status applies where its fault type is answered | 2026-10-13 | accepted | | [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | diff --git a/design/aspects.md b/design/aspects.md index c303acc..83d0afa 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -15,7 +15,7 @@ marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a generic value object, one that derives from another value object, a wrapped type that is not a class, struct or enum, or one that is a value object: itself, one that reaches it again, or - any other, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares + any other, or a closed set of the same project, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares equal only to its own type, which is not value equality), CMTK1007 (the name of the `GetValue`/`ValueOrNull` class is taken, see below), CMTK1008 (a comparison member beside the seam, see below), CMTK1009 (a hand-written constructor, see below), CMTK1022 (an abstract type, @@ -167,6 +167,12 @@ one's rules in its `Create`. Refused rather than made to work: a surface that follows the project layout is the silent failure this repo exists to prevent, and lifting a refusal later breaks nobody, while adding one after 1.0.0 would. + - **A closed set of the same project** (decided 2026-10-13, + [decision 60](adr/0060-value-object-over-same-project-closed-set-refused.md)) is refused the same + way and for the same reason: `ClosedSetAspect` runs last in `AspectOrder`, and over a set of its own + project `OverSet : IValue` lacked `Parse`, `TryParse`, `IParsable` and its own type converter + while its JSON worked; over the same set in a referenced project it had them. The message names the + set and says to declare it in a referenced project or to wrap its name. - The satellites keep their own refusal ([OpenAPI](openapi.md)): a value object declared by hand, with the interfaces and `GeneratedValueObjectAttribute<,>` the satellites read ([Discovery](discovery.md)), can still wrap itself. - **Every JSON key goes through the wrapped type's own converter**, which knows the type's key diff --git a/design/closed-sets.md b/design/closed-sets.md index ddd8842..bd1dd29 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -49,6 +49,12 @@ CMTK1013 to CMTK1021, all Errors, each reported on the declaration it names; the fixes is in the [Generators README](../src/CodoMetis.TypeKit.Generators/README.md). A set the aspect cannot complete is never left half-generated. +A value object over a set declared in the same project is CMTK1005 +([decision 60](adr/0060-value-object-over-same-project-closed-set-refused.md)): `ClosedSetAspect` runs +after the value-object aspects, which do not see the set's `Parse`, `IParsable` and type converter, so +the value object was generated without its own. Over a set of a referenced project it is generated in +full (`ClosedSetTests`). + ## Open - A stable external name for a member, apart from its C# identifier, waits for a spike diff --git a/docs/closed-sets.md b/docs/closed-sets.md index cc12e66..633eea4 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -175,6 +175,11 @@ A declaration they cannot complete fails the build, naming it; the generators' | writes a generated member by hand, `ToString()` apart | CMTK1011 | | is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | +A value object that wraps a set declared in the same project is refused too (CMTK1005): the set's +parsing and type converter are generated after the value object's, which would be generated without +its own. Declare the set in a referenced project, where a value object over it is generated in full, +or wrap the member's name. + The set's own code can still call its private constructor beside its members, and such an instance has no name: `Name`, `ToString()`, `Match`, JSON and the column throw for it, rather than carry a null ([why](../src/CodoMetis.TypeKit/README.md#an-instance-that-is-not-a-member-of-its-set)). diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index aabc6c7..ca1a0b9 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -347,7 +347,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1002 | A struct not declared `readonly`. | | CMTK1003 | More than one marker, such as `IValue, IValue`. | | CMTK1004 | An `IValidatedValue` whose `TSelf` is another type. | -| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value`, derived from another value object, or wrapping an array, a pointer, a nullable type, a generic type (a tuple included) or a value object (itself included). Wrap what the other value object wraps instead. | +| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value`, derived from another value object, or wrapping an array, a pointer, a nullable type, a generic type (a tuple included), a value object (itself included) or a closed set declared in the same project. Wrap what the other value object wraps instead; declare the set in a referenced project, where the value object gets its parsing and type converter, or wrap its name. | | CMTK1006 | A record class not declared `sealed`. | | CMTK1007 | The `{TSelf}Extensions` companion's name is taken by a declared type or by another value object's companion. | | CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index bedfe52..9fc5487 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -14,26 +14,27 @@ namespace CodoMetis.TypeKit.Generators; internal sealed partial class ValueObjectAspect : TypeAspect { private readonly string? _companionClassNameOwner; - private readonly string? _wrappedValueObjectRefusal; + private readonly string? _wrappedTypeRefusal; /// /// What already owns the name of the companion class, as /// answered it in the fabric, or when the name is free. /// - /// + /// /// Why the value object cannot wrap what it wraps, as - /// answered it in the fabric, or - /// when what it wraps is not a value object. + /// answered it in the fabric, or + /// when what it wraps is neither a value object nor a closed set of the + /// same project. /// - public ValueObjectAspect(string? companionClassNameOwner, string? wrappedValueObjectRefusal) + public ValueObjectAspect(string? companionClassNameOwner, string? wrappedTypeRefusal) { _companionClassNameOwner = companionClassNameOwner; - _wrappedValueObjectRefusal = wrappedValueObjectRefusal; + _wrappedTypeRefusal = wrappedTypeRefusal; } public override void BuildAspect(IAspectBuilder builder) { - if (!TryResolve(builder, _wrappedValueObjectRefusal, out var kind, out var valueType)) + if (!TryResolve(builder, _wrappedTypeRefusal, out var kind, out var valueType)) { builder.SkipAspect(); return; @@ -109,7 +110,7 @@ public override void BuildAspect(IAspectBuilder builder) /// The one marker, the kind and the wrapped type, or an error for a declaration that cannot be /// generated. Generating it anyway would fail later with a far less useful message. /// - private static bool TryResolve(IAspectBuilder builder, string? wrappedValueObjectRefusal, out ValueObjectKind kind, [NotNullWhen(true)] out INamedType? valueType) + private static bool TryResolve(IAspectBuilder builder, string? wrappedTypeRefusal, out ValueObjectKind kind, [NotNullWhen(true)] out INamedType? valueType) { var target = builder.Target; var markers = ValueObjectTypes.Markers(target); @@ -227,9 +228,9 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp // Before the field is introduced: over a struct that wraps itself it has no layout (CS0523), // which Metalama reports as a bug in this aspect. - if (wrappedValueObjectRefusal is not null) + if (wrappedTypeRefusal is not null) { - builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, wrappedValueObjectRefusal))); + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, wrappedTypeRefusal))); return false; } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs index 6ceba7f..965e000 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs @@ -23,7 +23,8 @@ namespace CodoMetis.TypeKit.Generators; /// /// /// The aspect is handed what owns its companion class's name, and whether what it wraps is a value -/// object (). Both read types other than its +/// object or a closed set of the same project (). +/// Both read types other than its /// target, so they are computed here, where nothing has been introduced yet, rather than in an /// aspect. The factory below still runs in parallel on one code model, so they find a type by /// enumerating a collection, never by OfName, whose index is not safe for parallel callers @@ -38,7 +39,7 @@ public override void AmendProject(IProjectAmender amender) .Where(ValueObjectTypes.IsValueObject) .AddAspect(type => new ValueObjectAspect( CompanionClass.NameOwner(type), - ValueObjectTypes.WrappedValueObjectRefusal(type))); + ValueObjectTypes.WrappedTypeRefusal(type))); // An abstract type gets no aspect, so it is refused here, where it is selected. amender.SelectTypes(includeNestedTypes: true) diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs index bbe5364..14dff30 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs @@ -49,10 +49,36 @@ public static bool IsAbstractValueObject(INamedType type) => && !type.IsStatic && Markers(type).Count > 0; + /// + /// Why cannot be generated over what it wraps, read from types + /// other than itself: a value object () or a closed set of + /// the same project (). when it + /// wraps neither. Called from the fabric only, since it reads types other than the aspect's target + /// (). + /// + public static string? WrappedTypeRefusal(INamedType valueObject) => + WrappedValueObjectRefusal(valueObject) ?? SameProjectClosedSetRefusal(valueObject); + + /// + /// Why cannot be generated over the closed set it wraps, or + /// when what it wraps is no closed set declared in the same project. + /// + /// + /// The closed-set aspect runs after the value-object aspects (AspectOrder), and they do not + /// see what it introduces: over a set of its own project a value object was generated without + /// Parse, TryParse, IParsable and its own type converter, while its JSON worked, + /// and over the same set in a referenced project it got all of them (measured 2026-10-13). A + /// surface that follows the project layout is refused, as for a value object over a value object. + /// + private static string? SameProjectClosedSetRefusal(INamedType valueObject) => + WrappedNamedType(valueObject) is { } set && ClosedSetTypes.IsClosedSet(set) && !set.DeclaringAssembly.IsExternal + ? $"it wraps '{set.ToDisplayString()}', a closed set declared in the same project, whose parsing and type converter are generated after the value object's and would be missing from it; " + + "declare the set in a referenced project, or wrap its name ('string') instead" + : null; + /// /// Why cannot be generated over what it wraps, or - /// when that is not a value object. Called from the fabric only, since it - /// reads types other than the aspect's target (). + /// when that is not a value object. /// /// /// @@ -71,7 +97,7 @@ public static bool IsAbstractValueObject(INamedType type) => /// Create. /// /// - public static string? WrappedValueObjectRefusal(INamedType valueObject) + private static string? WrappedValueObjectRefusal(INamedType valueObject) { List chain = [valueObject]; var next = WrappedNamedType(valueObject); diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 310c0bc..a72a1b0 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -38,6 +38,8 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1005", "StructCycleOne")] [InlineData("CMTK1005", "StructCycleTwo")] [InlineData("CMTK1005", "WrapsAValueObject")] + [InlineData("CMTK1005", "WrapsASet")] + [InlineData("CMTK1005", "ValidatedOverASet")] [InlineData("CMTK1005", "ListBacked")] [InlineData("CMTK1005", "ImmutableArrayBacked")] [InlineData("CMTK1005", "PairBacked")] @@ -192,6 +194,19 @@ public void CMTK0012_reports_a_call_by_position_with_the_generators_names_in_the public void A_value_object_over_a_value_object_is_refused_with_what_it_reaches(string type, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith($"'{type}'") && error.Message.Contains(reason), $"CMTK1005 on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + /// + /// The closed-set aspect runs after the value-object aspects, which do not see what it introduces: + /// over a set of its own project a value object was generated without Parse, IParsable + /// and its own type converter, while over the same set in a referenced project it had them + /// ( holds that case). The refusal names the set and both ways out. + /// + [Theory] + [InlineData("WrapsASet")] + [InlineData("ValidatedOverASet")] + public void A_value_object_over_a_closed_set_of_its_own_project_is_refused_with_the_way_out(string type) => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith($"'{type}' cannot be generated as a value object: it wraps 'SetToWrap', a closed set declared in the same project") + && error.Message.Contains("declare the set in a referenced project, or wrap its name ('string') instead"), $"CMTK1005 on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + /// /// A generic wrapped type failed inside the generated code (LAMA0611). The refusal says why, since /// the type is often a perfectly good type: its equality is not guaranteed to be value equality. @@ -619,6 +634,17 @@ public sealed partial record CycleTwo : IValue; // from the same project, parsing, comparison and the type converter were silently missing. public readonly partial record struct WrapsAValueObject : IValue; + // Over a closed set of the same project, the value-object aspects ran before the set's and + // missed its parsing and type converter: no Parse, no IParsable, the base TypeConverter. + public sealed partial class SetToWrap : IClosedSet { public static readonly SetToWrap A = new(); } + + public readonly partial record struct WrapsASet : IValue; + + public sealed partial record ValidatedOverASet : IValidatedValue + { + public static Result Create(SetToWrap value) => Result.Error(Fault.Refused); + } + // Companion classes are named after the whole nesting chain, so these two coexist. public sealed class Order { public readonly partial record struct Id : IValue; } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 6e4b0e8..06464fd 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -3,9 +3,16 @@ using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.CompilerServices; using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; namespace CodoMetis.TypeKit.Generators.Tests; +/// +/// A value object over a closed set of a referenced project, which is generated in full: over a set of +/// its own project it is refused (CMTK1005, ). +/// +public readonly partial record struct OverProbeStatus : IValue; + /// /// The closed-set family, generated for and . /// Measured first in spikes/ClosedSets; each claim there is a test here. @@ -114,6 +121,25 @@ public void TryParse_and_Parse_find_a_member_by_name_and_refuse_without_quoting( refusal.Message.ShouldContain("#a-closed-set-has-no-member-of-that-name"); } + /// + /// Over a set of a referenced project the value-object aspects see what the closed-set aspect + /// generated there, so the value object parses and converts through the set's name. A guard against + /// the same-project refusal reaching further than the project it is about. + /// + [Fact] + public void A_value_object_over_a_set_of_a_referenced_project_parses_and_converts_by_name() + { + OverProbeStatus.Parse("Shipped", null).Value.ShouldBeSameAs(ProbeStatus.Shipped); + OverProbeStatus.TryParse("Nope", null, out _).ShouldBeFalse(); + + var converter = TypeDescriptor.GetConverter(typeof(OverProbeStatus)); + converter.GetType().ShouldNotBe(typeof(TypeConverter), "the value object's own type converter, not the base one"); + converter.ConvertFromInvariantString("Open").ShouldBe(OverProbeStatus.From(ProbeStatus.Open)); + converter.ConvertToInvariantString(OverProbeStatus.From(ProbeStatus.Cancelled)).ShouldBe("Cancelled"); + + JsonSerializer.Serialize(OverProbeStatus.From(ProbeStatus.Open)).ShouldBe("\"Open\""); + } + /// /// A header or a query string a client typed: the ordinal parse stays the default, and the /// ignore-case one is opted into at the call. Unknown names are refused as the ordinal one refuses them. From fade6bca937d97de2fa85648a74c61f827348862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 22:57:16 +0200 Subject: [PATCH 112/147] docs: say which text reads back, that number handling by attribute misses a value object, and link absolutely from a package README - Parse(x.ToString(), null) gives the value back only where the wrapped type's default text is lossless: not for TimeOnly ("03:04" for 03:04:05.123), DateTime (fraction and Kind) or DateTimeOffset (fraction), as for the BCL types; the type converter and interpolation write the same. The docs name them and point at "O" and the JSON form. A test pins it. - A [JsonNumberHandling] on the containing type reaches the numbers the serializer writes itself, never a value object's converter, and on the property the serializer refuses it; the options and a source-generated context do reach it. Stated where "the wrapped type's JSON" is claimed, and pinned by a test. - The Generators README's one relative link broke on nuget.org; it is absolute now, and a conventions test refuses a relative link in any package README. Co-Authored-By: Claude Opus 5.5 --- design/aspects.md | 17 +++++++--- docs/json.md | 10 ++++++ docs/value-objects.md | 7 ++++- src/CodoMetis.TypeKit.Generators/README.md | 26 +++++++++++++--- .../ValueObjectFormattableAspect.Templates.cs | 6 ++-- .../ValueObjectParsableAspect.cs | 6 ++-- .../DocumentationLinkTests.cs | 18 +++++++++++ .../AllocationAndNumberHandlingTests.cs | 30 ++++++++++++++++++ .../FormattingAndComparisonTests.cs | 31 +++++++++++++++++++ 9 files changed, 138 insertions(+), 13 deletions(-) diff --git a/design/aspects.md b/design/aspects.md index 83d0afa..a4ed29f 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -51,9 +51,14 @@ type parameters reach an explicit implementation. `Parse` is `TryParse` and a throw (below). - **Parsing culture (decided 2026-09-27).** In the generated `Parse`/`TryParse` a null `IFormatProvider` means the invariant culture, unlike the BCL, because the generated `ToString()` - is invariant: `Parse(x.ToString(), null)` must round-trip, and with the current culture it parsed - "1.5" as 15 in de-DE. A provider that is given is used as given. The type converter already did - this. + is invariant: what it writes must read back the same wherever the process runs, and with the + current culture "1.5" was parsed as 15 in de-DE. A provider that is given is used as given. The + type converter already did this. The text is the wrapped type's own default text, so it is as + lossless as that, no more (measured 2026-10-13): `Parse(x.ToString(), null)` gives `x` back for the + numbers, `Guid`, `DateOnly`, `TimeSpan`, `BigInteger`, `Version` and `Uri`, and not for `TimeOnly` + (`03:04` for 03:04:05.123), `DateTime` (fraction and `Kind`) and `DateTimeOffset` (fraction), whose + invariant short patterns drop them; the type converter and interpolation write the same text. The + docs say so and point at `"O"` and the JSON form. - **Formatting culture (decided 2026-09-27),** the counterpart: a null provider means the invariant culture in the generated `ToString(format, provider)`, both `TryFormat`s and `IConvertible`. Interpolation, `string.Format` and `Convert.ToString` pass null, and with the current culture @@ -69,7 +74,11 @@ writes and reads its values and keys through `GeneratedJsonPlan`, the converter the options have for `T` or else the built-in (or NodaTime's) one: exactly what the serializer writes for a `T`, so a host's converter, number handling, enum converter and `DictionaryKeyPolicy` apply, and swapping a - primitive for a value object changes no JSON. The earlier per-family readers and writers wrote + primitive for a value object changes no JSON. A `[JsonNumberHandling]` on the containing type or + the property is the exception (measured 2026-10-13): the serializer applies it to the numbers it + writes itself, the plan reads only the options' and the wrapped type's contract, and a converter + cannot see its caller's attribute, so the docs say to set it on the options or the context. The + earlier per-family readers and writers wrote `TimeOnly` as `"13:45:30.0000000"` and `DateTime` keys with seven fractional digits, ignored a host's converter for `Guid`, `DateTime` and integer keys, and skipped the key policy. `JsonParityTests` compares a value object with its wrapped type for 14 families under five option diff --git a/docs/json.md b/docs/json.md index 6a9ec76..93dda6f 100644 --- a/docs/json.md +++ b/docs/json.md @@ -52,6 +52,16 @@ for every wrapped-type family under several sets of options. One addition of the `DateTime` is written as UTC, a `Local` one converted and an `Unspecified` one taken as UTC with its digits kept, so a stored instant reads back as the instant it was. +One exception: number handling given by an attribute rather than by the options. A +`[JsonNumberHandling]` on a record or class applies to the number properties the serializer writes +itself, and a value object's converter cannot see it. Under +`[JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)]`, +`record Counted(int Count)` writes `{"Count":"5"}` and reads `"5"`, while `record Counted(Quantity Count)` +writes `{"Count":5}` and refuses `"5"` with a `JsonException`, which ASP.NET Core answers with 400. On +a value-object property the serializer refuses the attribute outright (`InvalidOperationException`: +it is valid only on a number). Set the number handling on the options, or on a source-generated +context's `[JsonSourceGenerationOptions(NumberHandling = ...)]`: both reach the value object. + ## Refusals Every generated way in applies `Create`, and a refusal names the value object and the rule, or the diff --git a/docs/value-objects.md b/docs/value-objects.md index 8363236..819659d 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -185,7 +185,12 @@ bool sortable = typeof(IComparable).IsAssignableFrom(typeof(Homepage)) The generated members follow what the wrapped type offers: parsing where it parses, formatting where it formats, comparison where it is comparable. A `Uri` parses and formats but has no ordering; NodaTime's types parse through their type converters. A null `IFormatProvider` means the invariant culture -throughout, so `Parse(x.ToString(), null)` round-trips wherever the process runs. +throughout, so a value object's text is the same wherever the process runs, and `Parse(x.ToString(), null)` +gives `x` back wherever the wrapped type's own text is lossless: numbers, `Guid`, `DateOnly`, +`TimeSpan`, `Version`, `Uri`, enums, strings. `TimeOnly`, `DateTime` and `DateTimeOffset` print the +invariant culture's short patterns, as the BCL types do, and lose seconds (`TimeOnly`) or the fraction +of a second (and a `DateTime` its `Kind`) on the way: use `x.ToString("O", null)` or the JSON form for +those ([Parsing and formatting](../src/CodoMetis.TypeKit.Generators/README.md#parsing-and-formatting)). What cannot be wrapped is refused at build time (CMTK1005): a generic type, a tuple included, because a `List` compares by reference; a nullable; an array; and another value object, since wrapping diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index ca1a0b9..07e2c52 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -275,6 +275,15 @@ property by an `OrderId` therefore changes no JSON, and a test compares the two family under several sets of options. One addition: a `DateTime` is written as UTC, a `Local` one converted and an `Unspecified` one taken as UTC with its digits kept. +One exception: number handling given by an attribute rather than by the options. A +`[JsonNumberHandling]` on the containing type reaches the number properties the serializer writes +itself, and a value object's converter never sees it: under +`[JsonNumberHandling(WriteAsString | AllowReadingFromString)]`, `record Dto(int Count)` writes +`{"Count":"5"}` and reads `"5"`, while `record Dto(Quantity Count)` writes `{"Count":5}` and refuses +`"5"` with a `JsonException`. On the property itself the serializer refuses the attribute for any type +but a number (`InvalidOperationException`). Set the number handling on the options, or on a +source-generated context's `[JsonSourceGenerationOptions]`, which do reach the value object. + Numbers and dates are read as strictly as the serializer reads the wrapped type. What it cannot read is a `JsonException` naming the value object and the wrapped type ("Quantity could not read the JSON value as Int32."), with no inner exception that could carry the text; ASP.NET Core answers it with 400. @@ -314,9 +323,18 @@ var shown = $"{Amount.From(1.5m)}"; // "1.5" ``` A null `IFormatProvider` means the invariant culture in every generated `Parse`, `TryParse`, -`ToString` and `TryFormat`, so `Parse(x.ToString(), null)` round-trips wherever the process runs. A -provider that is given is used as given where the wrapped type's parsing and formatting take one; a -`Uri`, a NodaTime type and a type parsed through a constructor or a provider-less `Parse` ignore it. +`ToString` and `TryFormat`, so the text does not depend on the culture the process runs in: +`ToString()` is the wrapped type's own default text under the invariant culture, and the type +converter and `$"{x}"` write the same. It reads back as the same value wherever the wrapped type's +own does: numbers (`float`, `double` and `Half` included), `BigInteger`, `Guid`, `DateOnly`, +`TimeSpan`, `Version`, `Uri`, enums and strings. It does not for `TimeOnly`, `DateTime` and +`DateTimeOffset`, whose default text drops what the invariant culture's short patterns drop: +`TimeOnly(3, 4, 5, 123)` prints `03:04`, a `DateTime` loses its fraction of a second and its `Kind`, +a `DateTimeOffset` its fraction. Format those with `"O"` (`x.ToString("O", null)`), which a `TimeOnly` +and a `DateTimeOffset` read back exactly, or keep them in their JSON form, which a `DateTime` reads +back as the UTC instant it wrote. A provider that is given is used as given where the wrapped type's +parsing and formatting take one; a `Uri`, a NodaTime type and a type parsed through a constructor or a +provider-less `Parse` ignore it. Text the wrapped type cannot read is a `FormatException` naming the value object and the wrapped type ("Quantity could not read the input as Int32."), an overflow included, with no inner exception: @@ -333,7 +351,7 @@ implementation of `IComparable`, `IComparable` or `IComparisonOperators` A database cannot run it: it orders the column by the wrapped value. The type then carries `[HandWrittenOrdering]`, and an EF Core query that compares, orders, or takes the `Min` or `Max` of it is refused, naming the value object, rather than answered in the column's order -([CodoMetis.TypeKit.EntityFrameworkCore](../CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +([CodoMetis.TypeKit.EntityFrameworkCore](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). Compare or order by `.Value` where the wrapped value's order is meant. ## Build errors diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs index a87bde6..79bfdbc 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs @@ -40,8 +40,10 @@ public string FormattableToStringTemplate(string? format, IFormatProvider? forma // Overrides the record-generated ToString() with the invariant culture. The BCL's own ToString() // uses the current culture; this one does not, because a value object's text is an identifier // or a wire value more often than a display string, and the generated Parse treats a null - // provider as invariant too, so Parse(x.ToString(), null) round-trips. Callers who need a - // culture pass it: x.ToString(format, culture), or string.Create(culture, $"{x}"). + // provider as invariant too, so Parse(x.ToString(), null) reads it back as far as the wrapped + // type's default text is lossless (TimeOnly, DateTime and DateTimeOffset drop seconds or a + // fraction in theirs). Callers who need a culture pass it: x.ToString(format, culture), or + // string.Create(culture, $"{x}"). [Template] public string ToStringOverrideTemplate() => meta.This.ToString(null, CultureInfo.InvariantCulture); diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs index 838122b..25105ad 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs @@ -35,8 +35,10 @@ internal sealed class ParsableImplementationArguments /// /// The provider the generated parsing hands to the wrapped type. A null provider means the /// invariant culture, not the current one as in the BCL: the generated ToString() is - /// invariant, so Parse(x.ToString(), null) has to round-trip, and with the current - /// culture it parsed "1.5" as 15 in de-DE. A provider that is given is used as given. + /// invariant, so what it writes has to read back the same wherever the process runs, and with the + /// current culture "1.5" was parsed as 15 in de-DE. It is as lossless as the wrapped type's own + /// default text: TimeOnly, DateTime and DateTimeOffset lose seconds or a + /// fraction in it. A provider that is given is used as given. /// public const string Provider = "(provider ?? global::System.Globalization.CultureInfo.InvariantCulture)"; diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs index 2ec8461..ab4abe5 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/DocumentationLinkTests.cs @@ -40,6 +40,24 @@ public void A_relative_link_opens_a_file_and_its_anchor_is_a_heading(string loca headings.ShouldContain(link.Anchor, $"{location} links #{link.Anchor}, which is no heading of {link.Path}. The headings are: {string.Join(", ", headings)}."); } + public static TheoryData PackageReadmes => [.. Repository.ShippingProjects().Select(project => $"src/{project}/README.md")]; + + /// + /// A package README is packed into its package, and nuget.org renders it there, where a link + /// relative to the repository opens nothing. It links another file by its absolute GitHub address, + /// as every other package README does; an in-page anchor stays relative. + /// + [Theory] + [MemberData(nameof(PackageReadmes))] + public void A_package_README_links_other_files_by_their_absolute_address(string readme) + { + PackageReadmes.Count.ShouldBeGreaterThanOrEqualTo(5); + File.Exists(Path.Combine(Repository.Root, readme)).ShouldBeTrue($"{readme} does not exist."); + + Links.Value.Where(link => link.Page == readme).Select(link => link.Location).ShouldBeEmpty( + $"{readme} is packed into its package, where a relative link opens nothing on nuget.org. Link https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/... instead."); + } + /// The positive control: links with and without anchors are found, external and in-page links are left alone, and the slug is GitHub's. [Fact] public void Links_and_headings_are_read_as_GitHub_reads_them() diff --git a/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs index 731b0db..504fdb2 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/AllocationAndNumberHandlingTests.cs @@ -170,6 +170,36 @@ public void A_number_follows_the_options_number_handling_exactly_as_the_wrapped_ } } + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public sealed record RawCounted(int Count); + + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public sealed record CountedOnTheType(ProbeCount Count); + + public sealed record CountedOnTheProperty([property: JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] ProbeCount Count); + + public sealed record Counted(ProbeCount Count); + + /// + /// What the docs state as the one exception to "the wrapped type's JSON": number handling given by + /// an attribute. On the containing type it reaches the numbers the serializer writes itself and not + /// a value object, whose converter cannot see it; on the property the serializer refuses it for + /// anything but a number. The options carry it to the value object. Pinned so the docs cannot say + /// more than holds. + /// + [Fact] + public void Number_handling_by_attribute_does_not_reach_a_value_object_and_the_options_do() + { + JsonSerializer.Serialize(new RawCounted(5)).ShouldBe("""{"Count":"5"}"""); + JsonSerializer.Serialize(new CountedOnTheType(ProbeCount.From(5))).ShouldBe("""{"Count":5}"""); + Should.Throw(() => JsonSerializer.Deserialize("""{"Count":"5"}""")); + Should.Throw(() => JsonSerializer.Serialize(new CountedOnTheProperty(ProbeCount.From(5)))); + + var options = new JsonSerializerOptions { NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString }; + JsonSerializer.Serialize(new Counted(ProbeCount.From(5)), options).ShouldBe("""{"Count":"5"}"""); + JsonSerializer.Deserialize("""{"Count":"5"}""", options)!.Count.ShouldBe(ProbeCount.From(5)); + } + private static string Outcome(Func action) { try diff --git a/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs index b247276..db35523 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/FormattingAndComparisonTests.cs @@ -62,6 +62,37 @@ public void A_null_format_provider_means_the_invariant_culture_in_every_format() } } + /// + /// What the docs state about Parse(x.ToString(), null): the text is the wrapped type's + /// default text under the invariant culture, so it gives the value back where that text is + /// lossless, and not for TimeOnly, DateTime and DateTimeOffset, whose short + /// patterns drop seconds or a fraction, as the BCL types' own do. "O" reads back for the + /// two that keep their offset or have none. Pinned so the docs cannot say more than holds. + /// + [Fact] + public void Parse_of_ToString_gives_the_value_back_where_the_wrapped_types_text_is_lossless() + { + static bool RoundTrips(T value) where T : IParsable, IEquatable => T.Parse(value.ToString()!, null).Equals(value); + + RoundTrips(ProbeAmount.From(1.500m)).ShouldBeTrue(); + RoundTrips(ProbeRatio.From(0.1 + 0.2)).ShouldBeTrue(); + RoundTrips(ProbeId.From(Guid.Parse("0199a3f4-1c00-7000-8000-000000000001"))).ShouldBeTrue(); + RoundTrips(ProbeDate.From(new DateOnly(2026, 10, 13))).ShouldBeTrue(); + RoundTrips(ProbeDuration.From(new TimeSpan(1, 2, 3, 4, 567))).ShouldBeTrue(); + RoundTrips(ProbeUri.From(new Uri("https://example.com/a?b=c#d"))).ShouldBeTrue(); + + var time = ProbeTime.From(new TimeOnly(3, 4, 5, 123)); + time.ToString().ShouldBe("03:04"); + RoundTrips(time).ShouldBeFalse(); + ProbeTime.Parse(time.ToString("O", null), null).ShouldBe(time); + + RoundTrips(ProbeTimestamp.From(new DateTime(2026, 10, 13, 3, 4, 5, 123, DateTimeKind.Utc))).ShouldBeFalse("the fraction of a second is lost"); + + var moment = ProbeMoment.From(new DateTimeOffset(2026, 10, 13, 3, 4, 5, 123, TimeSpan.FromHours(2))); + RoundTrips(moment).ShouldBeFalse("the fraction of a second is lost"); + ProbeMoment.Parse(moment.ToString("O", null), null).ShouldBe(moment); + } + [Fact] public void A_span_formattable_value_formats_into_a_span_and_into_UTF_8() { From 25abc08b2dc94e6b13750c16f55616987083322a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:00:35 +0200 Subject: [PATCH 113/147] test: pin a closed set's ignore-case parse to ordinal, every initialization guard, and number handling for every number family - Under tr-TR, Parse and TryParse with ignoreCase: true find Invoice for INVOICE and invoice, and I for i; switching the comparison to the current culture's stayed green before. - A set read in its own static initializer through Parse(name, ignoreCase), TryParse(name, ignoreCase), IParsable.TryParse or a JSON dictionary key is refused as still initializing; without the guards the first three handed out a null member and the key read threw ArgumentNullException. - Every number family GeneratedJsonPlan lists (byte to UInt128, Half and the 128-bit integers included) writes and reads what its wrapped type does under every number handling, as a value, a property and a key; dropping long and float from the list stayed green before. Co-Authored-By: Claude Opus 5.5 --- .../ClosedSetInitializationTests.cs | 52 ++++++++++++-- .../ClosedSetTests.cs | 37 ++++++++++ .../JsonParityTests.cs | 72 +++++++++++++++++++ 3 files changed, 155 insertions(+), 6 deletions(-) diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs index 69fddb8..439da71 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetInitializationTests.cs @@ -34,22 +34,62 @@ public sealed partial class EarlyJsonStatus : IClosedSet public static readonly EarlyJsonStatus Closed = new(); } +/// See , through Parse(name, ignoreCase). +public sealed partial class EarlyIgnoreCaseParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = [Parse("closed", ignoreCase: true)]; + public static readonly EarlyIgnoreCaseParseStatus Closed = new(); +} + +/// See , through TryParse(name, ignoreCase, out member). +public sealed partial class EarlyIgnoreCaseTryParseStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = TryParse("closed", ignoreCase: true, out var closed) ? [closed] : []; + public static readonly EarlyIgnoreCaseTryParseStatus Closed = new(); +} + +/// See , through the explicit IParsable<TSelf>.TryParse. +public sealed partial class EarlyParsableStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = EarlyReads.Parsable("Closed"); + public static readonly EarlyParsableStatus Closed = new(); +} + +/// See , through the JSON converter's dictionary-key read. +public sealed partial class EarlyJsonKeyStatus : IClosedSet +{ + public static readonly IReadOnlyList Defaults = [.. JsonSerializer.Deserialize>("{\"Closed\":1}")!.Keys]; + public static readonly EarlyJsonKeyStatus Closed = new(); +} + +/// Generic code that reads a set through IParsable<TSelf>, as a binder does. +public static class EarlyReads +{ + public static IReadOnlyList Parsable(string name) where T : IParsable => T.TryParse(name, null, out var member) ? [member] : []; +} + /// /// A closed set's static field initializers run while its members are still being created. All, /// filled once every member exists, was null there, and a field that read it held null for the /// process's life, typed as never null; a parse of a member declared below handed out null, and -/// TryParse answered true with it. Each refuses, naming the set and linking the README. +/// TryParse answered true with it, as did the ignore-case overloads and IParsable, and a +/// dictionary-key read threw . Each refuses, naming the set and +/// linking the README. /// public sealed class ClosedSetInitializationTests { - public static TheoryData Forms => ["All", "Parse", "TryParse", "JSON"]; + public static TheoryData Forms => ["All", "Parse", "TryParse", "JSON", "Parse ignoring case", "TryParse ignoring case", "IParsable", "JSON key"]; private static readonly Dictionary Touch)> Touch = new() { - ["All"] = ("EarlyAllStatus", () => EarlyAllStatus.Snapshot), - ["Parse"] = ("EarlyParseStatus", () => EarlyParseStatus.Defaults), - ["TryParse"] = ("EarlyTryParseStatus", () => EarlyTryParseStatus.Defaults), - ["JSON"] = ("EarlyJsonStatus", () => EarlyJsonStatus.Defaults), + ["All"] = ("EarlyAllStatus", () => EarlyAllStatus.Snapshot), + ["Parse"] = ("EarlyParseStatus", () => EarlyParseStatus.Defaults), + ["TryParse"] = ("EarlyTryParseStatus", () => EarlyTryParseStatus.Defaults), + ["JSON"] = ("EarlyJsonStatus", () => EarlyJsonStatus.Defaults), + ["Parse ignoring case"] = ("EarlyIgnoreCaseParseStatus", () => EarlyIgnoreCaseParseStatus.Defaults), + ["TryParse ignoring case"] = ("EarlyIgnoreCaseTryParseStatus", () => EarlyIgnoreCaseTryParseStatus.Defaults), + ["IParsable"] = ("EarlyParsableStatus", () => EarlyParsableStatus.Defaults), + ["JSON key"] = ("EarlyJsonKeyStatus", () => EarlyJsonKeyStatus.Defaults), }; [Theory] diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 06464fd..62a9974 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Globalization; using System.Text.Json; using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.CompilerServices; @@ -13,6 +14,13 @@ namespace CodoMetis.TypeKit.Generators.Tests; /// public readonly partial record struct OverProbeStatus : IValue; +/// A set whose names differ from their upper and lower cases by an i, which tr-TR does not case as English does. +public sealed partial class DocumentKind : IClosedSet +{ + public static readonly DocumentKind Invoice = new(); + public static readonly DocumentKind I = new(); +} + /// /// The closed-set family, generated for and . /// Measured first in spikes/ClosedSets; each claim there is a test here. @@ -164,6 +172,35 @@ public void TryParse_and_Parse_ignore_case_when_asked() refusal.Message.ShouldNotContain("nope"); } + /// + /// Ignoring case is ordinal, never the current culture's: in tr-TR i and I are not + /// one letter's two cases (each pairs with a dotted or dotless counterpart), and a comparison in the current + /// culture refused INVOICE, invoice and i for members named Invoice and + /// I, which an English-speaking server accepted. + /// + [Fact] + public void Ignoring_case_is_ordinal_whatever_the_current_culture() + { + var (culture, uiCulture) = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("tr-TR"); + + try + { + ReferenceEquals(DocumentKind.Parse("INVOICE", ignoreCase: true), DocumentKind.Invoice).ShouldBeTrue(); + ReferenceEquals(DocumentKind.Parse("invoice", ignoreCase: true), DocumentKind.Invoice).ShouldBeTrue(); + ReferenceEquals(DocumentKind.Parse("i", ignoreCase: true), DocumentKind.I).ShouldBeTrue(); + + DocumentKind.TryParse("INVOICE", ignoreCase: true, out var invoice).ShouldBeTrue(); + ReferenceEquals(invoice, DocumentKind.Invoice).ShouldBeTrue(); + DocumentKind.TryParse("i", ignoreCase: true, out var i).ShouldBeTrue(); + ReferenceEquals(i, DocumentKind.I).ShouldBeTrue(); + } + finally + { + (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture) = (culture, uiCulture); + } + } + /// What is stored or sent is the name as it is: JSON, the type converter and IParsable stay ordinal. [Fact] public void JSON_the_type_converter_and_IParsable_stay_ordinal() diff --git a/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs index d93abd5..20b26d1 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/JsonParityTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Numerics; using System.Text.Json; using System.Text.Json.Serialization; using CodoMetis.TypeKit.Generators.Probes; @@ -9,6 +10,19 @@ namespace CodoMetis.TypeKit.Generators.Tests; +// The number families the probes do not cover, for the number-handling cells below. +public readonly partial record struct ParityByte : IValue; +public readonly partial record struct ParitySByte : IValue; +public readonly partial record struct ParityShort : IValue; +public readonly partial record struct ParityUShort : IValue; +public readonly partial record struct ParityUInt : IValue; +public readonly partial record struct ParityLong : IValue; +public readonly partial record struct ParityULong : IValue; +public readonly partial record struct ParityFloat : IValue; +public readonly partial record struct ParityHalf : IValue; +public readonly partial record struct ParityInt128 : IValue; +public readonly partial record struct ParityUInt128 : IValue; + /// /// A value object writes exactly the bytes the serializer writes for the value it wraps, under the same /// options, as a value, as a property and as a dictionary key, and reads what the serializer reads. @@ -86,6 +100,64 @@ public void A_converter_the_host_registers_is_the_one_that_writes() JsonSerializer.Serialize(new Dictionary { [ProbeName.From("Ab c")] = 1 }, Families["string"].Options("camel-case keys")).ShouldBe("{\"ab c\":1}"); } + /// + /// Every number type the plan lists as one the serializer applies number handling to. Only the + /// serializer applies it, so the plan sends these through the serializer wherever the handling + /// changes the output; a type missing from its list is written by its converter directly, and a + /// value object wrote 123 where a is written "123". + /// + private static readonly Dictionary Numbers = new() + { + ["byte"] = NumberCase(ParityByte.From(123), (byte)123), + ["sbyte"] = NumberCase(ParitySByte.From(-12), (sbyte)-12), + ["short"] = NumberCase(ParityShort.From(-1234), (short)-1234), + ["ushort"] = NumberCase(ParityUShort.From(1234), (ushort)1234), + ["int"] = NumberCase(ProbeCount.From(42), 42), + ["uint"] = NumberCase(ParityUInt.From(123u), 123u), + ["long"] = NumberCase(ParityLong.From(9_007_199_254_740_993L), 9_007_199_254_740_993L), + ["ulong"] = NumberCase(ParityULong.From(18_000_000_000_000_000_000UL), 18_000_000_000_000_000_000UL), + ["float"] = NumberCase(ParityFloat.From(0.25f), 0.25f), + ["double"] = NumberCase(ProbeRatio.From(0.25), 0.25), + ["decimal"] = NumberCase(ProbeAmount.From(1.50m), 1.50m), + ["Half"] = NumberCase(ParityHalf.From((Half)0.5), (Half)0.5), + ["Int128"] = NumberCase(ParityInt128.From(Int128.MaxValue), Int128.MaxValue), + ["UInt128"] = NumberCase(ParityUInt128.From(UInt128.MaxValue), UInt128.MaxValue), + }; + + private static readonly JsonNumberHandling[] NumberHandlings = + [ + JsonNumberHandling.Strict, + JsonNumberHandling.AllowReadingFromString, + JsonNumberHandling.WriteAsString, + JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString, + JsonNumberHandling.AllowNamedFloatingPointLiterals, + JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString, + ]; + + public static TheoryData NumberCells => + [ + .. from family in Numbers.Keys + from handling in NumberHandlings + select (family, handling) + ]; + + [Fact] + public void Every_number_family_and_every_number_handling_is_a_cell() => NumberCells.Count.ShouldBe(14 * 6); + + [Theory] + [MemberData(nameof(NumberCells))] + public void A_number_value_object_follows_the_options_number_handling_as_its_wrapped_type(string family, JsonNumberHandling handling) + { + var @case = Numbers[family]; + + @case.AssertParity(new JsonSerializerOptions { NumberHandling = handling }, $"{family} under {handling}"); + } + + private static ParityCase NumberCase(TValueObject valueObject, T raw) + where TValueObject : IValueObject + where T : notnull, INumberBase => + new(valueObject, raw, number => number.ToString(null, CultureInfo.InvariantCulture), text => T.Parse(text, CultureInfo.InvariantCulture), nodaTime: false); + private static ParityCase Case(TValueObject valueObject, T raw, Func format, Func parse, bool nodaTime = false) where TValueObject : IValueObject where T : notnull => From 53188ed2d6a4430b9983068edf1bd47aca8afac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:02:01 +0200 Subject: [PATCH 114/147] fix: a value object over object is refused (CMTK1005) IValue compiled, and its converter wrote OverObject.From(5) as {} where an object property holding 5 is written as 5: the serializer writes an object by its run-time type, and the plan calls the object converter directly. It is CMTK1005 now, beside the other wrapped types that cannot be generated. An interface and an abstract class were measured beside it (plain and [JsonPolymorphic]): written and read exactly as the serializer does for the declared type, refusals included, so they stay allowed, and a build-outcome guard keeps them so. Co-Authored-By: Claude Opus 5.5 --- design/aspects.md | 7 +++++ docs/value-objects.md | 6 ++-- src/CodoMetis.TypeKit.Generators/README.md | 2 +- .../ValueObjectAspect.cs | 12 ++++++++ .../BuildOutcomeTests.cs | 30 +++++++++++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/design/aspects.md b/design/aspects.md index a4ed29f..bc5a34d 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -160,6 +160,13 @@ value a `HashSet` kept. - **A nullable wrapped type is CMTK1005.** The markers' `notnull` constraint is only a warning (CS8714), and `IValue` then failed inside the generated code as LAMA0611/0612. +- **`object` as the wrapped type is CMTK1005 (decided 2026-10-13).** The serializer writes an `object` + by the run-time type of what it holds; the plan calls the `object` converter directly, which does + not, so `OverObject.From(5)` was written as `{}` where an `object` property holding 5 is written as + 5. Measured beside it: an interface (`IDisposable`, a `[JsonPolymorphic]` one) and an abstract class + (a plain one, an abstract record, a `[JsonPolymorphic]` one), and `ValueType` and `Array`, are written and + read exactly as the serializer writes and reads the declared type, refusals included, so they stay + allowed. - **A value object never wraps a value object (decided 2026-09-27).** CMTK1005, answered in the fabric because it reads the wrapped types (see "An aspect never scans its namespace" below). - **A cycle** (`sealed partial record SelfWrap : IValue`, or A wraps B wraps A) has no diff --git a/docs/value-objects.md b/docs/value-objects.md index 819659d..e5f7ad0 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -193,8 +193,10 @@ of a second (and a `DateTime` its `Kind`) on the way: use `x.ToString("O", null) those ([Parsing and formatting](../src/CodoMetis.TypeKit.Generators/README.md#parsing-and-formatting)). What cannot be wrapped is refused at build time (CMTK1005): a generic type, a tuple included, because -a `List` compares by reference; a nullable; an array; and another value object, since wrapping -what it wraps says the same with one type. +a `List` compares by reference; a nullable; an array; `object`, which the serializer writes by the +type of what it holds and a value object's converter cannot; and another value object, since wrapping +what it wraps says the same with one type. An interface or an abstract class is wrapped, and its JSON +is the serializer's for that type, polymorphic where the type declares it. ## Which factory diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 07e2c52..b581fae 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -365,7 +365,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1002 | A struct not declared `readonly`. | | CMTK1003 | More than one marker, such as `IValue, IValue`. | | CMTK1004 | An `IValidatedValue` whose `TSelf` is another type. | -| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value`, derived from another value object, or wrapping an array, a pointer, a nullable type, a generic type (a tuple included), a value object (itself included) or a closed set declared in the same project. Wrap what the other value object wraps instead; declare the set in a referenced project, where the value object gets its parsing and type converter, or wrap its name. | +| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value`, derived from another value object, or wrapping `object`, an array, a pointer, a nullable type, a generic type (a tuple included), a value object (itself included) or a closed set declared in the same project. Wrap what the other value object wraps instead; declare the set in a referenced project, where the value object gets its parsing and type converter, or wrap its name. | | CMTK1006 | A record class not declared `sealed`. | | CMTK1007 | The `{TSelf}Extensions` companion's name is taken by a declared type or by another value object's companion. | | CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index 9fc5487..a1a153b 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -215,6 +215,18 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } + // object holds a value of any type, and the serializer writes an object by the type of what it + // holds, which a converter called directly does not: OverObject.From(5) was written as {} where an + // object property holding 5 is written as 5 (measured 2026-10-13). An interface or an abstract + // class is written and read as the serializer writes it (its declared members, or polymorphic + // where the type says so), so it stays allowed. + if (namedValueType.SpecialType == SpecialType.Object) + { + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, + "the wrapped type 'object' holds a value of any type, which the serializer writes by its run-time type and a value object's converter cannot (5 was written as {}); wrap the type the value has"))); + return false; + } + // A generic wrapped type, tuples included, failed inside the generated code (LAMA0611): its // name lost its type arguments there, and a named tuple broke the attribute. Refused rather than // made to work, because a value object's equality is its wrapped value's, and a generic type's diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index a72a1b0..be9b74c 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -31,6 +31,7 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1005", "Outer.Middle.DeeplyNested")] [InlineData("CMTK1005", "ArrayBacked")] [InlineData("CMTK1005", "NullableBacked")] + [InlineData("CMTK1005", "ObjectBacked")] [InlineData("CMTK1005", "DerivesFromAValueObject")] [InlineData("CMTK1005", "WrapsItself")] [InlineData("CMTK1005", "CycleOne")] @@ -217,6 +218,27 @@ public void A_value_object_over_a_closed_set_of_its_own_project_is_refused_with_ public void A_generic_wrapped_type_is_refused_with_the_reason(string type, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith($"'{type}'") && error.Message.Contains(reason) && error.Message.Contains("compares by reference"), consumer.Output); + /// + /// A value object over object compiled, and its converter wrote OverObject.From(5) as + /// {} where an object property holding 5 is written as 5: the serializer writes an + /// object by its run-time type, which a converter called directly does not. + /// + [Fact] + public void A_value_object_over_object_is_refused_with_the_reason() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith("'ObjectBacked' cannot be generated as a value object: the wrapped type 'object' holds a value of any type") + && error.Message.Contains("wrap the type the value has"), consumer.Output); + + /// + /// An interface or an abstract class is written and read as the serializer writes and reads it (its + /// declared members, polymorphic where the type declares it, refused on reading where the serializer + /// refuses it), so it is wrapped. A guard against the refusal of object reaching further. + /// + [Theory] + [InlineData("InterfaceBacked")] + [InlineData("AbstractClassBacked")] + public void An_interface_or_an_abstract_class_is_wrapped(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); + /// /// Metalama writes its override of the record's ToString() into every part of a declaration, /// and the second copy failed inside the generated code (LAMA0611, CS0111). @@ -591,6 +613,14 @@ public static class Outer { public static class Middle { public readonly part // refusal, the generated JSON converter and parsing fail to compile (LAMA0611/0612). public readonly partial record struct NullableBacked : IValue; + // Written as {} where an object property holding the same value is written as that value. + public readonly partial record struct ObjectBacked : IValue; + + // Written and read as the serializer writes and reads the declared type: wrapped. + public readonly partial record struct InterfaceBacked : IValue; + + public readonly partial record struct AbstractClassBacked : IValue; + // A record class that can be derived from: equality across a hierarchy is not value // equality, and a derived value object failed inside the generated code (LAMA0611). public partial record NotSealed : IValue; From e4b751c6e93291f3ee952e07f46e88cf650de281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:12:31 +0200 Subject: [PATCH 115/147] fix: declarations that failed as LAMA0611 or a bare CS0111 are refused by name or generated - A value object named as a member the generators introduce (From, TryFrom, Parse, TryParse, TryFormat, MinValue, CompareTo, ...) is CMTK1005, and a closed set named as one of its generated members (Name, All, Match, ...) is CMTK1021; both were CS0542 inside the generated code. - IValue is CMTK1005: Enum.Parse(string) was taken for a static Parse of its own (CS0411). The static-Parse strategy now takes only a non-generic one. - An [Obsolete(error: true)] on a value object or a type it is nested in made the companion class's use of it CS0619. The companion class now carries the nearest [Obsolete], so the value object is generated. - A hand-written operator == or != between two of the type is CMTK1011 beside C#'s own CS0111, as a hand-written Equals(TSelf) is. A compiler error in the build-outcome consumer hid every LAMA0611 inside a declaration's own file (a seeded generic Parse was reported alone and unseen beside the refused sets' CS0535), so the refused closed sets are built in a second consumer, the value objects' build must have no compiler error, and the operator declarations, whose CS0111 is C#'s, have a consumer of their own. Co-Authored-By: Claude Opus 5.5 --- design/aspects.md | 16 + design/test-strategy.md | 8 + docs/closed-sets.md | 2 +- docs/value-objects.md | 7 +- .../AspectDiagnostics.cs | 7 + .../ClosedSetAspect.cs | 5 + src/CodoMetis.TypeKit.Generators/README.md | 13 +- .../ValueObjectAspect.cs | 20 +- .../ValueObjectCompanionAspect.cs | 18 ++ .../ValueObjectDeclaration.cs | 20 +- .../ValueObjectParsableAspect.cs | 4 +- .../BuildOutcomeTests.cs | 289 +++++++++++++----- .../EqualityOperatorBuildTests.cs | 82 +++++ 13 files changed, 399 insertions(+), 92 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/EqualityOperatorBuildTests.cs diff --git a/design/aspects.md b/design/aspects.md index bc5a34d..6425c3a 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -144,6 +144,22 @@ - CMTK1005 adds a generic wrapped type, tuples included (its equality is not value equality: a `List` compares by reference), a value object named `Value` (CS0542), and a `file`-local one (LAMA0001, which failed the whole project). +- **Build failures that named nothing the user wrote (decided 2026-10-13, the pre-tag review).** Each + was LAMA0611 or a bare compiler error: + - A value object named as any other member the generators introduce (`From`, `TryFrom`, + `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, `MinValue`, `MaxValue`, + `CompareTo`, the private helpers) is CMTK1005, whatever it wraps, so the rule is one list + (`ValueObjectDeclaration.GeneratedMemberNames`); a closed set named as one of its generated members + (`Name`, `All`, `Match`, ...) is CMTK1021, a second definition of that id with the set's own message. + Both were CS0542 inside the generated code. + - `IValue` is CMTK1005: `Enum.Parse(string)` was taken for a static `Parse` of its + own (CS0411). The static-`Parse` strategy now takes only a non-generic one. + - An `[Obsolete(error: true)]` on a value object, or on a type it is nested in, made the companion + class's use of it CS0619. It is made to work, not refused: the companion class carries the nearest + `[Obsolete]`, and C# reports no use of an obsolete type inside an obsolete declaration. Nothing else + generated names the value object outside it. A closed set is generated inside itself and built. + - A hand-written `operator ==`/`!=` between two of the type is CMTK1011 beside C#'s own CS0111, which + names only `op_Equality`; it is a hand-written equality like `Equals(TSelf)`. - **A `DateTime` is UTC on the wire (decided 2026-09-27).** Written and read, value and key, through `GeneratedJson.AsUtc`: a `Local` value converts, and an `Unspecified` one (an offset-less string, a column without a time zone) is taken as UTC with its digits kept. `ToUniversalTime()` read it as diff --git a/design/test-strategy.md b/design/test-strategy.md index f86129c..7ceb167 100644 --- a/design/test-strategy.md +++ b/design/test-strategy.md @@ -30,3 +30,11 @@ once before ([Option and Result](option-result.md)). Seeded: a README or sample edited alone, the bare fault back in both, the committed READMEs, a changed SQL, JSON or string output, an opt-out on a compiled block or without a reason, an unlabelled fence, and either parser finding nothing each fail their test. +7. **Build outcomes** (2026-10-13). A declaration the generators refuse is an error, which no compiling + project can observe, so `BuildOutcomeTests` builds throwaway consumers against the generators and the + analyzer and reads their errors: every refusal by id and message, and no LAMA error, which would + name nothing the user wrote. A compiler error in a consumer hides every LAMA0611 inside a + declaration's own file (measured with a seeded generic `Parse`: reported alone, unseen beside a + CS0535), so the refused closed sets, which do not implement their interface, are built apart from + the value objects, whose build must have no compiler error, and a declaration whose error is C#'s own + (CS0111) has a consumer of its own (`EqualityOperatorBuildTests`). diff --git a/docs/closed-sets.md b/docs/closed-sets.md index 633eea4..573cae8 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -171,7 +171,7 @@ A declaration they cannot complete fails the build, naming it; the generators' | is generic, or nested in a generic type | CMTK1018 | | has a member not created with `new` where it is declared: `Legacy = Open` would give one instance two names | CMTK1019 | | is a record: `with` would copy a member into an instance that is none | CMTK1020 | -| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), which collide in `Match`'s parameters and in `TryParse(name, ignoreCase: true)` | CMTK1021 | +| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), which collide in `Match`'s parameters and in `TryParse(name, ignoreCase: true)`, or is itself named as a generated member (`Name`, `Match`): a member cannot have the name of its type | CMTK1021 | | writes a generated member by hand, `ToString()` apart | CMTK1011 | | is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | diff --git a/docs/value-objects.md b/docs/value-objects.md index e5f7ad0..bb6965d 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -194,9 +194,10 @@ those ([Parsing and formatting](../src/CodoMetis.TypeKit.Generators/README.md#pa What cannot be wrapped is refused at build time (CMTK1005): a generic type, a tuple included, because a `List` compares by reference; a nullable; an array; `object`, which the serializer writes by the -type of what it holds and a value object's converter cannot; and another value object, since wrapping -what it wraps says the same with one type. An interface or an abstract class is wrapped, and its JSON -is the serializer's for that type, polymorphic where the type declares it. +type of what it holds and a value object's converter cannot; `System.Enum`, which is no enum; and +another value object, since wrapping what it wraps says the same with one type. An interface or an +abstract class is wrapped, and its JSON is the serializer's for that type, polymorphic where the type +declares it. ## Which factory diff --git a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs index ad78a85..b12c288 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectDiagnostics.cs @@ -84,6 +84,13 @@ internal static class AspectDiagnostics public static readonly DiagnosticDefinition<(INamedType Type, string Member, string Reason)> ClosedSetMemberNameTaken = new("CMTK1021", Severity.Error, "'{0}.{1}' {2}, so '{0}' is not generated: rename the member." + SeeBuildErrors); + /// + /// CMTK1021's collision the other way round: the set itself, not one of its members, has the name of + /// a generated member, which C# refuses inside a type of that name (CS0542). + /// + public static readonly DiagnosticDefinition ClosedSetNamedAsGeneratedMember = + new("CMTK1021", Severity.Error, "'{0}' has the name of a member the generators introduce into it, and a member cannot have the name of its type, so '{0}' is not generated: rename the set." + SeeBuildErrors); + public static readonly DiagnosticDefinition AbstractValueObject = new("CMTK1022", Severity.Error, "'{0}' is abstract, so it is not generated: an abstract type cannot be a value object. A value held as '{0}' would have no Value, and its JSON would be '{{}}'. Declare the marker on a sealed type." + SeeBuildErrors); } diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index 70320bb..2dfe267 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -67,6 +67,11 @@ public override void BuildAspect(IAspectBuilder builder) // name to TryParse(name, ignoreCase: true), and where the difference is the first letter's // they gave Match one parameter name twice (LAMA0611, CS0100). var generatedNames = ClosedSetTypes.GeneratedNames(target); + + // A set named as a generated member failed inside the generated code (LAMA0611, CS0542: a + // member cannot have the name of its type), naming nothing the user wrote. + if (generatedNames.Contains(target.Name)) problems.Add(ClosedSetNamedAsGeneratedMember.WithArguments(target)); + foreach (var member in members.Where(member => generatedNames.Contains(member.Name))) problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, "has the name of a member the generators introduce"))); foreach (var shared in members.GroupBy(member => member.Name, StringComparer.OrdinalIgnoreCase).Where(group => group.Count() > 1)) diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index b581fae..3f94803 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -152,11 +152,14 @@ since C# keeps it in every derived record; one that is not sealed is CMTK1011, b `IConvertible` on it are refused as they are on the value object. Equality is the wrapped value's, as the ordering, the JSON and the column are, so a hand-written -`Equals(TSelf)`, `GetHashCode()` or `IEquatable.Equals` is CMTK1011: it would make values equal -that sort apart. To make values that differ only in form equal, such as "abc" and "ABC", normalise them +`Equals(TSelf)`, `GetHashCode()`, `IEquatable.Equals`, or `operator ==` or `!=` between two of +the type, is CMTK1011: it would make values equal that sort apart. To make values that differ only in form equal, such as "abc" and "ABC", normalise them in `Create` (a plain value object becomes an `IValidatedValue` for that), so that equal values hold the same wrapped value. +An `[Obsolete]` on a value object, or on a type it is nested in, is copied to its `{TSelf}Extensions` +class, which names the value object outside it: with `error: true` that use failed the build. + A value object holds its wrapped value and nothing else. Its JSON, parsing, type converter and column carry that value alone, while a record's equality compares every field, so any other instance state would be lost on a round trip or make equal values unequal. An instance field, an auto-property, a @@ -365,13 +368,13 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1002 | A struct not declared `readonly`. | | CMTK1003 | More than one marker, such as `IValue, IValue`. | | CMTK1004 | An `IValidatedValue` whose `TSelf` is another type. | -| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value`, derived from another value object, or wrapping `object`, an array, a pointer, a nullable type, a generic type (a tuple included), a value object (itself included) or a closed set declared in the same project. Wrap what the other value object wraps instead; declare the set in a referenced project, where the value object gets its parsing and type converter, or wrap its name. | +| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value` or as another member the generators introduce (`From`, `TryFrom`, `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, `MinValue`, `MaxValue`, `CompareTo`), derived from another value object, or wrapping `object`, `System.Enum`, an array, a pointer, a nullable type, a generic type (a tuple included), a value object (itself included) or a closed set declared in the same project. Wrap what the other value object wraps instead; declare the set in a referenced project, where the value object gets its parsing and type converter, or wrap its name. | | CMTK1006 | A record class not declared `sealed`. | | CMTK1007 | The `{TSelf}Extensions` companion's name is taken by a declared type or by another value object's companion. | | CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | | CMTK1009 | A hand-written instance constructor, including a positional record's parameter list such as `OrderId(Guid Value)`. The constructor and `Value` are generated; a static constructor is fine. | | CMTK1010 | Declared in more than one `partial` part: Metalama 2026.1 writes the generated `ToString()` into every part, which does not compile. A part a source generator adds, such as a `[GeneratedRegex]` method's, does not count. | -| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members", and for a closed set every generated member but `ToString()`, by signature, `IParsable`, `[JsonConverter]` and `[TypeConverter]`), an explicit implementation of an interface they implement, a hand-written equality, a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | +| CMTK1011 | A hand-written member or attribute the generators introduce (see "Your own members", and for a closed set every generated member but `ToString()`, by signature, `IParsable`, `[JsonConverter]` and `[TypeConverter]`), an explicit implementation of an interface they implement, a hand-written equality (an `operator ==` or `!=` between two of the type included, which C# also reports as CS0111), a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | | CMTK1012 | Instance state besides the wrapped value: a field, an auto-property, a `required` member or a field-like event, declared or inherited. Compute it from `Value`, make it static, or wrap a type that holds all of it. | | CMTK1013 | A closed set not declared `partial`. | | CMTK1014 | A closed set not declared `sealed`: a set with subtypes is not closed. | @@ -381,7 +384,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1018 | A closed set that is generic or nested in a generic type. | | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | -| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), or whose name differs from another member's only in case: they collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), a set itself named as one of them (a member cannot have the name of its type), or a member whose name differs from another member's only in case: they collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference. | | CMTK1022 | An abstract type with a value-object marker: it would get no member, and a value held as it, of a type derived from it, would have no `Value` and be written as `{}`. Declare the marker on the sealed type. An abstract closed set is CMTK1014. | The analyzers that come with the base package add CMTK0001 to CMTK0012; see diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index a1a153b..9bb23ac 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -139,14 +139,20 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } - // Its generated property is named Value, and a member cannot have its type's name (CS0542, - // inside the generated code). + // A member cannot have its type's name (CS0542, inside the generated code, as LAMA0611): Value, + // and every other member the generators introduce into a value object of some wrapped type. if (target.Name == nameof(IValueObject<,>.Value)) { builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, $"a value object cannot be named '{nameof(IValueObject<,>.Value)}', the name of its generated property; rename it"))); return false; } + if (ValueObjectDeclaration.GeneratedMemberNames.Contains(target.Name)) + { + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, $"a value object cannot be named '{target.Name}', the name of a member the generators introduce, since a member cannot have the name of its type; rename it"))); + return false; + } + // The companion class and the attribute's type arguments sit outside the file, where a file-local // type, or one nested in it, cannot be named, and Metalama crashed on it (LAMA0001, naming nothing). if (ValueObjectDeclaration.IsFileLocal(target)) @@ -227,6 +233,16 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } + // System.Enum is the base of every enum and no enum itself: its generic Enum.Parse(string) + // was taken for a Parse of its own, and the generated parsing failed inside the generated code + // (LAMA0611, CS0411), naming nothing the user wrote. + if (namedValueType.Equals(TypeFactory.GetNamedType(typeof(Enum)))) + { + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, + "the wrapped type 'Enum' is the base of every enum and no enum itself; wrap the enum type"))); + return false; + } + // A generic wrapped type, tuples included, failed inside the generated code (LAMA0611): its // name lost its type arguments there, and a named tuple broke the attribute. Refused rather than // made to work, because a value object's equality is its wrapped value's, and a generic type's diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs index 85f9ba2..f5efadf 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectCompanionAspect.cs @@ -59,6 +59,13 @@ public override void BuildAspect(IAspectBuilder builder) type.IsStatic = true; type.IsPartial = true; type.AddAttribute(CodeAnnotations.CompilerGenerated); + + // The class names the value object outside it, where an + // [Obsolete(error: true)] on it or on a type it is nested in + // failed inside the generated code (LAMA0611, CS0619). Inside + // an obsolete declaration C# reports no use of one. + if (ObsoleteAttribute(builder.Target) is { } obsolete) + type.AddAttribute(obsolete.ToAttributeConstruction()); }); companionClass.IntroduceMethod( @@ -93,4 +100,15 @@ public override void BuildAspect(IAspectBuilder builder) ); } + /// The [Obsolete] of , or of the nearest type it is nested in that has one. + private static IAttribute? ObsoleteAttribute(INamedType type) + { + for (INamedType? current = type; current is not null; current = current.DeclaringType) + { + if (current.Attributes.FirstOrDefault(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(ObsoleteAttribute)))) is { } obsolete) + return obsolete; + } + + return null; + } } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 0f4fa81..aa613cb 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -168,6 +168,19 @@ private static int SkipAttributeList(string text, int start) return text.Length; } + /// + /// The names of the members the generators introduce into a value object, besides Value, for + /// some marker or wrapped type: a value object of one of these names failed inside the generated + /// code (LAMA0611, CS0542: a member cannot have the name of its type), naming nothing the user wrote + /// (measured 2026-10-13 for From, TryFrom, Parse, TryParse, + /// TryFormat, MinValue and CompareTo). Refused whatever it wraps, so the rule is + /// one list rather than one per wrapped type. What a record synthesizes (ToString, + /// Equals) is C#'s own error, and an explicit implementation (IConvertible's) does not + /// collide. + /// + public static IReadOnlyList GeneratedMemberNames { get; } = + ["From", "TryFrom", "FromKnownGood", "Revalidate", "Parse", "TryParse", "TryFormat", "MinValue", "MaxValue", "CompareTo", "_value", "__FromJson", "__FromText", "__TryFromText"]; + /// /// What the declaration writes by hand that a generator introduces itself, which would fail inside /// the generated code (LAMA0500, LAMA0503, LAMA0512, LAMA0521, LAMA0531, LAMA0611) naming nothing @@ -383,7 +396,8 @@ .. SelfAndBases(target).SelectMany(type => type.Methods /// IEquatable<TSelf>.Equals. The record kept it, while the generated ordering, the JSON /// dictionary keys and the EF Core column went on comparing the wrapped value: a case-insensitive /// Equals made "abc" and "ABC" equal with CompareTo 32, so a HashSet held one and - /// a SortedSet two. + /// a SortedSet two. An operator == or != between two of the type collides with + /// the record's own, which C# reports as CS0111 naming only op_Equality; the error says why. /// public static IReadOnlyList EqualityDeclaredByHand(INamedType target) { @@ -397,6 +411,10 @@ .. target.Methods ? method.ExplicitInterfaceImplementations.Any(implemented => implemented.DeclaringType.Definition.Equals(equatable)) : (method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.Equals(target)) || method is { Name: nameof(GetHashCode), Parameters.Count: 0 })) + .Select(Describe), + .. target.Methods + .Where(method => method is { IsStatic: true, IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, OperatorKind: OperatorKind.Equality or OperatorKind.Inequality, Parameters: [{ } left, { } right] } + && left.Type.ToNonNullable().Equals(target) && right.Type.ToNonNullable().Equals(target)) .Select(Describe) ]; } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs index 25105ad..f150e3c 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.cs @@ -216,7 +216,9 @@ internal static ValueParseStrategy ResolveStrategy(INamedType valueType) if (valueType.Constructors.Any(constructor => constructor is { Accessibility: Accessibility.Public, Parameters: [{ Type.SpecialType: SpecialType.String }] })) return ValueParseStrategy.StringConstructor; - if (valueType.Methods.Any(method => method is { Name: "Parse", IsStatic: true, Accessibility: Accessibility.Public, Parameters: [{ Type.SpecialType: SpecialType.String }] })) + // Not a generic one, such as Enum.Parse(string), whose type argument the generated call + // cannot infer (LAMA0611, CS0411). + if (valueType.Methods.Any(method => method is { Name: "Parse", IsStatic: true, Accessibility: Accessibility.Public, TypeParameters.Count: 0, Parameters: [{ Type.SpecialType: SpecialType.String }] })) return ValueParseStrategy.StaticParse; return ValueParseStrategy.Unsupported; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index be9b74c..8f6c117 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -32,6 +32,7 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1005", "ArrayBacked")] [InlineData("CMTK1005", "NullableBacked")] [InlineData("CMTK1005", "ObjectBacked")] + [InlineData("CMTK1005", "EnumBacked")] [InlineData("CMTK1005", "DerivesFromAValueObject")] [InlineData("CMTK1005", "WrapsItself")] [InlineData("CMTK1005", "CycleOne")] @@ -47,6 +48,11 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1005", "TupleBacked")] [InlineData("CMTK1005", "NamedTupleBacked")] [InlineData("CMTK1005", "Named.Value")] + [InlineData("CMTK1005", "VoNames.Parse")] + [InlineData("CMTK1005", "VoNames.From")] + [InlineData("CMTK1005", "VoNames.TryFrom")] + [InlineData("CMTK1005", "VoNames.CompareTo")] + [InlineData("CMTK1005", "VoNames.MinValue")] [InlineData("CMTK1005", "FileLocal")] [InlineData("CMTK1005", "FileLocalBelowRegion")] [InlineData("CMTK1005", "FileLocalBelowPragma")] @@ -138,6 +144,8 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1021", "SetWithMemberNamedAll.All")] [InlineData("CMTK1021", "SetWithCasedMembers.open")] [InlineData("CMTK1021", "SetWithCaseVariants.EXPRESS")] + [InlineData("CMTK1021", "SetNames.Name")] + [InlineData("CMTK1021", "SetNames.Match")] [InlineData("CMTK1011", "SetWithIgnoreCaseTryParse")] [InlineData("CMTK1011", "SetWithIgnoreCaseParse")] public void A_declaration_that_cannot_be_generated_is_an_error(string id, string type) => @@ -218,6 +226,36 @@ public void A_value_object_over_a_closed_set_of_its_own_project_is_refused_with_ public void A_generic_wrapped_type_is_refused_with_the_reason(string type, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith($"'{type}'") && error.Message.Contains(reason) && error.Message.Contains("compares by reference"), consumer.Output); + /// + /// A type named as a member the generators introduce into it failed inside the generated code + /// (LAMA0611, CS0542: a member cannot have the name of its type), naming nothing the user wrote: + /// a closed set named Name, a value object named Parse. Each is refused by name. + /// + [Theory] + [InlineData("CMTK1021", "SetNames.Name", "'SetNames.Name' has the name of a member the generators introduce into it, and a member cannot have the name of its type, so 'SetNames.Name' is not generated: rename the set.")] + [InlineData("CMTK1005", "VoNames.Parse", "'VoNames.Parse' cannot be generated as a value object: a value object cannot be named 'Parse', the name of a member the generators introduce, since a member cannot have the name of its type; rename it.")] + public void A_type_named_as_a_generated_member_is_refused_by_name(string id, string type, string message) => + consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith(message), $"{id} on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); + + /// + /// System.Enum is no enum: its generic Enum.Parse<TEnum>(string) was taken for a + /// Parse of its own, and the generated parsing failed (LAMA0611, CS0411). + /// + [Fact] + public void A_value_object_over_System_Enum_is_refused_with_the_reason() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith("'EnumBacked' cannot be generated as a value object: the wrapped type 'Enum' is the base of every enum and no enum itself; wrap the enum type"), consumer.Output); + + /// + /// The companion class names the value object outside it, and an [Obsolete(error: true)] on + /// the value object, or on a type it is nested in, failed there (LAMA0611, CS0619). The companion + /// carries the attribute now, and inside an obsolete declaration C# reports no use of one. + /// + [Theory] + [InlineData("Retired")] + [InlineData("RetiredHolder")] + public void An_obsolete_value_object_is_generated(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}"), consumer.Output); + /// /// A value object over object compiled, and its converter wrote OverObject.From(5) as /// {} where an object property holding 5 is written as 5: the serializer writes an @@ -231,11 +269,14 @@ public void A_value_object_over_object_is_refused_with_the_reason() => /// /// An interface or an abstract class is written and read as the serializer writes and reads it (its /// declared members, polymorphic where the type declares it, refused on reading where the serializer - /// refuses it), so it is wrapped. A guard against the refusal of object reaching further. + /// refuses it), so it is wrapped. A guard against the refusal of object reaching further. A type + /// whose only static Parse(string) is generic is wrapped without parsing: the generic one was + /// taken for its own and failed inside the generated code (LAMA0611, CS0411), as for System.Enum. /// [Theory] [InlineData("InterfaceBacked")] [InlineData("AbstractClassBacked")] + [InlineData("GenericParserBacked")] public void An_interface_or_an_abstract_class_is_wrapped(string type) => consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); @@ -410,7 +451,8 @@ public void A_static_constructor_is_allowed() => /// an exception in an aspect or a compile error in the code it generated surfaces as LAMA0041 or /// LAMA0611, which tells the user nothing about their declaration. CS0535 is the one compiler /// error that accompanies a refused closed set: its interface has members the generators would - /// have implemented, and the CMTK error beside it says why they did not. + /// have implemented, and the CMTK error beside it says why they did not. A declaration whose error is + /// the compiler's own is built apart (). /// [Fact] public void Every_error_is_one_of_the_intended_ones() => @@ -422,12 +464,21 @@ public void Every_error_is_one_of_the_intended_ones() => /// did not implement. 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. /// + /// + /// A compiler error in a project hid every LAMA0611 inside a declaration's own file, so the build + /// that holds the value objects has none: what an aspect generated that does not compile is seen + /// there, by . + /// + [Fact] + public void The_declarations_build_without_a_compiler_error() => + consumer.DeclarationErrors.ShouldNotContain(error => error.Id.StartsWith("CS", StringComparison.Ordinal), consumer.Output); + [Fact] public void CS0535_names_only_a_refused_closed_set() => consumer.Errors.Where(error => error.Id == "CS0535") .ShouldAllBe(error => RefusedClosedSet().IsMatch(error.Message), consumer.Output); - [GeneratedRegex(@"^'(?:Consumer\.)?Set\w+(?:)?' does not implement interface member ")] + [GeneratedRegex(@"^'(?:Consumer\.)?Set\w+(?:\.\w+)?(?:)?' does not implement interface member ")] private static partial Regex RefusedClosedSet(); /// @@ -512,64 +563,6 @@ public sealed partial class Consumer : IAsyncLifetime namespace Consumer; - public sealed class SetNotPartial : IClosedSet { public static readonly SetNotPartial A = new(); } - - public partial class SetNotSealed : IClosedSet { public static readonly SetNotSealed A = new(); } - - public sealed partial class SetWithoutMembers : IClosedSet { } - - public sealed partial class SetWithPublicConstructor : IClosedSet { public static readonly SetWithPublicConstructor A = new(); public SetWithPublicConstructor() { } } - - public sealed partial class SetWithProperty : IClosedSet { public static readonly SetWithProperty A = new(); public static SetWithProperty B => A; } - - public sealed partial class SetWithMutableField : IClosedSet { public static readonly SetWithMutableField A = new(); public static SetWithMutableField B = A; } - - public sealed partial class SetGeneric : IClosedSet> { public static readonly SetGeneric A = new(); } - - public sealed partial class SetWithInternalConstructor : IClosedSet { public static readonly SetWithInternalConstructor A = new(); internal SetWithInternalConstructor() { } } - - public sealed partial class SetWithAlias : IClosedSet { public static readonly SetWithAlias Open = new(); public static readonly SetWithAlias Legacy = Open; } - - public sealed partial class SetWithFactoryMember : IClosedSet { public static readonly SetWithFactoryMember A = new(); public static readonly SetWithFactoryMember B = Make(); private static SetWithFactoryMember Make() => A; } - - public sealed partial class SetWithUninitializedMember : IClosedSet { public static readonly SetWithUninitializedMember A = new(); public static readonly SetWithUninitializedMember B; static SetWithUninitializedMember() { B = new(); } } - - public sealed partial class SetWithTrailingCall : IClosedSet { public static readonly SetWithTrailingCall A = new(); public static readonly SetWithTrailingCall B = new SetWithTrailingCall().Same(); private SetWithTrailingCall Same() => A; } - - public sealed partial record SetRecord : IClosedSet { public static readonly SetRecord A = new(); } - - // Abstract, so a closed set it is not: refused as unsealed, never skipped. - public abstract partial class SetAbstract : IClosedSet { } - - public sealed partial class SetWithName : IClosedSet { public static readonly SetWithName A = new(); public string Name => "A"; } - - public sealed partial class SetWithExplicitAll : IClosedSet { public static readonly SetWithExplicitAll A = new(); static System.Collections.Generic.IReadOnlyList IClosedSet.All => [A]; } - - public sealed partial class SetWithParsable : IClosedSet, System.IParsable { public static readonly SetWithParsable A = new(); } - - [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.StringConverter))] - public sealed partial class SetWithTypeConverterAttribute : IClosedSet { public static readonly SetWithTypeConverterAttribute A = new(); } - - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] - public sealed partial class SetWithJsonConverterAttribute : IClosedSet { public static readonly SetWithJsonConverterAttribute A = new(); } - - public sealed partial class SetWithMemberNamedParse : IClosedSet { public static readonly SetWithMemberNamedParse A = new(); public static readonly SetWithMemberNamedParse Parse = new(); } - - public sealed partial class SetWithMemberNamedToString : IClosedSet { public static readonly new SetWithMemberNamedToString ToString = new(); } - - public sealed partial class SetWithMemberNamedAll : IClosedSet { public static readonly SetWithMemberNamedAll All = new(); } - - public sealed partial class SetWithCasedMembers : IClosedSet { public static readonly SetWithCasedMembers Open = new(); public static readonly SetWithCasedMembers open = new(); } - - // Read as a member, it was none: its name threw and TryParse never found it. - public sealed partial class SetWithInternalMember : IClosedSet { public static readonly SetWithInternalMember A = new(); internal static readonly SetWithInternalMember Draft = new(); } - - public sealed partial class SetWithCaseVariants : IClosedSet { public static readonly SetWithCaseVariants Express = new(); public static readonly SetWithCaseVariants EXPRESS = new(); } - - public sealed partial class SetWithIgnoreCaseTryParse : IClosedSet { public static readonly SetWithIgnoreCaseTryParse A = new(); public static bool TryParse(string? name, bool ignoreCase, out SetWithIgnoreCaseTryParse? member) { member = A; return true; } } - - public sealed partial class SetWithIgnoreCaseParse : IClosedSet { public static readonly SetWithIgnoreCaseParse A = new(); public static SetWithIgnoreCaseParse Parse(string name, bool ignoreCase) => A; } - // Generated; the calls below are CMTK0012's, in the project that declares the set. public sealed partial class SetToMatch : IClosedSet { public static readonly SetToMatch New = new(); public static readonly SetToMatch Open = new(); } @@ -621,6 +614,10 @@ public static class Outer { public static class Middle { public readonly part public readonly partial record struct AbstractClassBacked : IValue; + public sealed class GenericParser { public static T Parse(string text) where T : new() => new(); } + + public readonly partial record struct GenericParserBacked : IValue; + // A record class that can be derived from: equality across a hierarchy is not value // equality, and a derived value object failed inside the generated code (LAMA0611). public partial record NotSealed : IValue; @@ -767,6 +764,33 @@ public sealed partial record ListBacked : IValue; } + // Named as a member the generators introduce: LAMA0611 (CS0542) inside the generated code. + public static class VoNames + { + public readonly partial record struct Parse : IValue; + + public readonly partial record struct From : IValue; + + public readonly partial record struct TryFrom : IValidatedValue + { + public static Result Create(int value) => Result.Error(Fault.Refused); + } + + public readonly partial record struct CompareTo : IValue; + + public readonly partial record struct MinValue : IValue; + } + + // Enum.Parse(string) was taken for a Parse of its own: LAMA0611 (CS0411). + public readonly partial record struct EnumBacked : IValue; + + // The companion class named the value object outside it: LAMA0611 (CS0619). + [System.Obsolete("Retired.", error: true)] + public readonly partial record struct Retired : IValue; + + [System.Obsolete("Retired.", error: true)] + public static class RetiredHolder { public readonly partial record struct Inner : IValue; } + file readonly partial record struct FileLocal : IValue; // A directive above the declaration, or a file-local type around it: the modifiers were read @@ -1289,12 +1313,93 @@ public Chained() : this(Fine.From(1)) { } } """; + /// + /// The closed sets the generators refuse, built apart: a refused set does not implement its + /// interface (CS0535), and beside a compiler error Metalama reported no error in the code it + /// generated into a declaration's own file, so in one project with the value objects a LAMA0611 + /// there went unseen (measured 2026-10-13 with a generic static Parse taken for the wrapped type's). + /// + private const string RefusedSets = + """ + using CodoMetis.TypeKit.ClosedSets; + + namespace Consumer; + + public sealed class SetNotPartial : IClosedSet { public static readonly SetNotPartial A = new(); } + + public partial class SetNotSealed : IClosedSet { public static readonly SetNotSealed A = new(); } + + public sealed partial class SetWithoutMembers : IClosedSet { } + + public sealed partial class SetWithPublicConstructor : IClosedSet { public static readonly SetWithPublicConstructor A = new(); public SetWithPublicConstructor() { } } + + public sealed partial class SetWithProperty : IClosedSet { public static readonly SetWithProperty A = new(); public static SetWithProperty B => A; } + + public sealed partial class SetWithMutableField : IClosedSet { public static readonly SetWithMutableField A = new(); public static SetWithMutableField B = A; } + + public sealed partial class SetGeneric : IClosedSet> { public static readonly SetGeneric A = new(); } + + public sealed partial class SetWithInternalConstructor : IClosedSet { public static readonly SetWithInternalConstructor A = new(); internal SetWithInternalConstructor() { } } + + public sealed partial class SetWithAlias : IClosedSet { public static readonly SetWithAlias Open = new(); public static readonly SetWithAlias Legacy = Open; } + + public sealed partial class SetWithFactoryMember : IClosedSet { public static readonly SetWithFactoryMember A = new(); public static readonly SetWithFactoryMember B = Make(); private static SetWithFactoryMember Make() => A; } + + public sealed partial class SetWithUninitializedMember : IClosedSet { public static readonly SetWithUninitializedMember A = new(); public static readonly SetWithUninitializedMember B; static SetWithUninitializedMember() { B = new(); } } + + public sealed partial class SetWithTrailingCall : IClosedSet { public static readonly SetWithTrailingCall A = new(); public static readonly SetWithTrailingCall B = new SetWithTrailingCall().Same(); private SetWithTrailingCall Same() => A; } + + public sealed partial record SetRecord : IClosedSet { public static readonly SetRecord A = new(); } + + // Abstract, so a closed set it is not: refused as unsealed, never skipped. + public abstract partial class SetAbstract : IClosedSet { } + + public sealed partial class SetWithName : IClosedSet { public static readonly SetWithName A = new(); public string Name => "A"; } + + public sealed partial class SetWithExplicitAll : IClosedSet { public static readonly SetWithExplicitAll A = new(); static System.Collections.Generic.IReadOnlyList IClosedSet.All => [A]; } + + public sealed partial class SetWithParsable : IClosedSet, System.IParsable { public static readonly SetWithParsable A = new(); } + + [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.StringConverter))] + public sealed partial class SetWithTypeConverterAttribute : IClosedSet { public static readonly SetWithTypeConverterAttribute A = new(); } + + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public sealed partial class SetWithJsonConverterAttribute : IClosedSet { public static readonly SetWithJsonConverterAttribute A = new(); } + + public sealed partial class SetWithMemberNamedParse : IClosedSet { public static readonly SetWithMemberNamedParse A = new(); public static readonly SetWithMemberNamedParse Parse = new(); } + + public sealed partial class SetWithMemberNamedToString : IClosedSet { public static readonly new SetWithMemberNamedToString ToString = new(); } + + public sealed partial class SetWithMemberNamedAll : IClosedSet { public static readonly SetWithMemberNamedAll All = new(); } + + public sealed partial class SetWithCasedMembers : IClosedSet { public static readonly SetWithCasedMembers Open = new(); public static readonly SetWithCasedMembers open = new(); } + + // Read as a member, it was none: its name threw and TryParse never found it. + public sealed partial class SetWithInternalMember : IClosedSet { public static readonly SetWithInternalMember A = new(); internal static readonly SetWithInternalMember Draft = new(); } + + public sealed partial class SetWithCaseVariants : IClosedSet { public static readonly SetWithCaseVariants Express = new(); public static readonly SetWithCaseVariants EXPRESS = new(); } + + public sealed partial class SetWithIgnoreCaseTryParse : IClosedSet { public static readonly SetWithIgnoreCaseTryParse A = new(); public static bool TryParse(string? name, bool ignoreCase, out SetWithIgnoreCaseTryParse? member) { member = A; return true; } } + + public sealed partial class SetWithIgnoreCaseParse : IClosedSet { public static readonly SetWithIgnoreCaseParse A = new(); public static SetWithIgnoreCaseParse Parse(string name, bool ignoreCase) => A; } + + // Named as a generated member: LAMA0611 (CS0542) inside the generated code. + public static class SetNames + { + public sealed partial class Name : IClosedSet { public static readonly Name A = new(); } + + public sealed partial class Match : IClosedSet { public static readonly Match A = new(); } + } + """; + /// /// Under the ignored artifacts/ rather than the temp folder: on macOS that is behind the /// /var symlink, and NuGet's relative project paths then resolve against the wrong side. /// private readonly string _directory = Path.Combine(RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + private readonly string _setsDirectory = Path.Combine(RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + /// A second file, so a declaration can be split across files. private const string OtherFile = """ @@ -1311,16 +1416,47 @@ public readonly partial record struct SplitAcrossFiles } """; + /// The errors of both builds. public IReadOnlyList Errors { get; private set; } = []; + /// The errors of the build without the refused closed sets, where no compiler error hides a LAMA0611. + public IReadOnlyList DeclarationErrors { get; private set; } = []; + public string Output { get; private set; } = ""; public async ValueTask InitializeAsync() { - Directory.CreateDirectory(_directory); + var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile }); + var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets }); + + var (declarationOutput, declarationErrors) = await declarations; + var (setOutput, setErrors) = await sets; + + DeclarationErrors = declarationErrors; + Errors = [.. declarationErrors.Concat(setErrors).Distinct()]; + Output = declarationOutput + setOutput; + } + + public ValueTask DisposeAsync() + { + foreach (var directory in new[] { _directory, _setsDirectory }) + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + + return ValueTask.CompletedTask; + } + + /// + /// Builds a consumer of the generators and the analyzer in from + /// , and reads its errors. A declaration whose build error is the + /// compiler's own (CS0111) belongs in a consumer of its own: beside it, Metalama reported no error + /// in the code it generated into a declaration's own file, which the other guards rely on. + /// + internal static async Task<(string Output, IReadOnlyList Errors)> Build(string directory, IReadOnlyDictionary sources) + { + Directory.CreateDirectory(directory); var src = Path.Combine(RepositoryRoot(), "src"); - await File.WriteAllTextAsync(Path.Combine(_directory, "Consumer.csproj"), + await File.WriteAllTextAsync(Path.Combine(directory, "Consumer.csproj"), $""" @@ -1334,12 +1470,12 @@ await File.WriteAllTextAsync(Path.Combine(_directory, "Consumer.csproj"), """); - await File.WriteAllTextAsync(Path.Combine(_directory, "Declarations.cs"), Declarations); - await File.WriteAllTextAsync(Path.Combine(_directory, "OtherFile.cs"), OtherFile); + foreach (var (name, source) in sources) + await File.WriteAllTextAsync(Path.Combine(directory, name), source); // Only errors are read, so the rules that ship as warnings or a suggestion are raised here. // CMTK0003 ships as an error, and is left to its own severity, so its reports here prove it. - await File.WriteAllTextAsync(Path.Combine(_directory, ".editorconfig"), + await File.WriteAllTextAsync(Path.Combine(directory, ".editorconfig"), """ root = true @@ -1353,9 +1489,9 @@ await File.WriteAllTextAsync(Path.Combine(_directory, ".editorconfig"), // Stop MSBuild's upward search here, so the repository's own build settings (warnings as // errors, documentation, central package management) do not apply to the consumer. - await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Build.props"), ""); - await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Build.targets"), ""); - await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Packages.props"), + await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Build.props"), ""); + await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Build.targets"), ""); + await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Packages.props"), "false"); // --no-dependencies and RestoreRecursive=false: the referenced projects are already built @@ -1364,7 +1500,7 @@ await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Packages.props" // passed on: without it the consumer builds in Debug and, in a Release run such as CI's, // finds no referenced assembly at all. using var process = Process.Start(new ProcessStartInfo( - "dotnet", ["build", _directory, "-c", Configuration, "--no-dependencies", "-p:RestoreRecursive=false", "-nodeReuse:false", "-clp:NoSummary"]) + "dotnet", ["build", directory, "-c", Configuration, "--no-dependencies", "-p:RestoreRecursive=false", "-nodeReuse:false", "-clp:NoSummary"]) { RedirectStandardOutput = true, RedirectStandardError = true, @@ -1375,16 +1511,11 @@ await File.WriteAllTextAsync(Path.Combine(_directory, "Directory.Packages.props" var errors = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); - Output = await output + await errors; - Errors = [.. DiagnosticLine().Matches(Output).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; + var text = await output + await errors; + IReadOnlyList found = [.. DiagnosticLine().Matches(text).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; - Errors.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{Output}"); - } - - public ValueTask DisposeAsync() - { - if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); - return ValueTask.CompletedTask; + found.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{text}"); + return (text, found); } /// The configuration this test assembly, and so the projects it references, was built in. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/EqualityOperatorBuildTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/EqualityOperatorBuildTests.cs new file mode 100644 index 0000000..f910271 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/EqualityOperatorBuildTests.cs @@ -0,0 +1,82 @@ +namespace CodoMetis.TypeKit.Generators.Tests; + +/// +/// A hand-written operator == or != between two of a value object's type collides with the +/// record's own, which C# reports as CS0111 naming only op_Equality. CMTK1011 beside it names the +/// operator and says why a value object's equality is its wrapped value's. +/// +/// +/// In a consumer of its own: beside a CS0111, Metalama reported no error in the code it generated into +/// a declaration's own file (a LAMA0611 that exists to catch went +/// unreported, measured 2026-10-13), so these declarations would blind the shared consumer. +/// +public sealed class EqualityOperatorBuildTests(EqualityOperatorBuildTests.Consumer consumer) : IClassFixture +{ + [Theory] + [InlineData("WithEqualityOperator", "WithEqualityOperator.operator ==(WithEqualityOperator, WithEqualityOperator), WithEqualityOperator.operator !=(WithEqualityOperator, WithEqualityOperator), so")] + [InlineData("WithEqualityOperatorClass", "WithEqualityOperatorClass.operator ==(WithEqualityOperatorClass?, WithEqualityOperatorClass?), WithEqualityOperatorClass.operator !=(WithEqualityOperatorClass?, WithEqualityOperatorClass?), so")] + public void A_hand_written_equality_operator_is_refused_by_name(string type, string members) => + consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares {members}") + && error.Message.Contains("a value object's equality is its wrapped value's"), consumer.Output); + + /// An operator between the value object and another type is the user's own, and C#'s, and is kept. + [Fact] + public void An_operator_with_another_type_is_not_refused() => + consumer.Errors.ShouldNotContain(error => error.Message.Contains("'WithMixedOperator'"), consumer.Output); + + /// Nothing but the refusals and C#'s own CS0111, which only the two operator fixtures cause. + [Fact] + public void Every_error_is_one_of_the_intended_ones() + { + consumer.Errors.Select(error => error.Id).Distinct().Order().ShouldBe(["CMTK1011", "CS0111"], ignoreOrder: false, customMessage: consumer.Output); + consumer.Errors.Where(error => error.Id == "CS0111") + .ShouldAllBe(error => error.Message.StartsWith("Type 'WithEqualityOperator") && (error.Message.Contains("'op_Equality'") || error.Message.Contains("'op_Inequality'")), consumer.Output); + } + + /// The consumer, built once per test run. + public sealed class Consumer : IAsyncLifetime + { + private const string Declarations = + """ + using CodoMetis.TypeKit.ValueObjects; + + namespace Consumer; + + public readonly partial record struct WithEqualityOperator : IValue + { + public static bool operator ==(WithEqualityOperator left, WithEqualityOperator right) => true; + + public static bool operator !=(WithEqualityOperator left, WithEqualityOperator right) => false; + } + + public sealed partial record WithEqualityOperatorClass : IValue + { + public static bool operator ==(WithEqualityOperatorClass? left, WithEqualityOperatorClass? right) => true; + + public static bool operator !=(WithEqualityOperatorClass? left, WithEqualityOperatorClass? right) => false; + } + + public readonly partial record struct WithMixedOperator : IValue + { + public static bool operator ==(WithMixedOperator left, int right) => left.Value == right; + + public static bool operator !=(WithMixedOperator left, int right) => left.Value != right; + } + """; + + private readonly string _directory = Path.Combine(BuildOutcomeTests.Consumer.RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + + public IReadOnlyList Errors { get; private set; } = []; + + public string Output { get; private set; } = ""; + + public async ValueTask InitializeAsync() => + (Output, Errors) = await BuildOutcomeTests.Consumer.Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations }); + + public ValueTask DisposeAsync() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + return ValueTask.CompletedTask; + } + } +} From 68058895c04db47ad6189695707118674d785f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:17:38 +0200 Subject: [PATCH 116/147] fix: a value object over a closed set of its own project is generated in full, since ClosedSetAspect runs first ClosedSetAspect ran last in AspectOrder, so the value-object aspects did not see the IParsable, Parse and [TypeConverter] it introduces into a set of their own project: such a value object had no parsing, the base TypeConverter, and a hand-written Parse kept silently, while over the same set in a referenced project it had all of them. ClosedSetAspect runs first now; it reads only the set's own declaration, which the value-object aspects never touch. This replaces the CMTK1005 refusal of 91a238d, which is gone with its tests and docs: a different aspect class can be ordered, unlike sibling instances of one. Decision 60 records it, and the refusal as the alternative dropped. Co-Authored-By: Claude Opus 5.5 --- ...bject-over-closed-set-generated-in-full.md | 28 +++ ...ct-over-same-project-closed-set-refused.md | 19 -- design/adr/README.md | 2 +- design/aspects.md | 23 ++- design/closed-sets.md | 16 +- docs/closed-sets.md | 9 +- .../AspectOrder.cs | 15 +- src/CodoMetis.TypeKit.Generators/README.md | 2 +- .../ValueObjectAspect.cs | 21 +- .../ValueObjectFabric.cs | 5 +- .../ValueObjectTypes.cs | 32 +--- .../BuildOutcomeTests.cs | 27 +-- .../ClosedSetTests.cs | 26 --- .../ClosedSetWrappingTests.cs | 180 ++++++++++++++++++ 14 files changed, 270 insertions(+), 135 deletions(-) create mode 100644 design/adr/0060-value-object-over-closed-set-generated-in-full.md delete mode 100644 design/adr/0060-value-object-over-same-project-closed-set-refused.md create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/ClosedSetWrappingTests.cs diff --git a/design/adr/0060-value-object-over-closed-set-generated-in-full.md b/design/adr/0060-value-object-over-closed-set-generated-in-full.md new file mode 100644 index 0000000..3c7f09d --- /dev/null +++ b/design/adr/0060-value-object-over-closed-set-generated-in-full.md @@ -0,0 +1,28 @@ +# 0060. A value object over a closed set is generated in full wherever the set is declared + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [Aspects](../aspects.md), [Closed sets](../closed-sets.md). + +`ClosedSetAspect` ran last in `AspectOrder`, after the value-object aspects, which therefore did not +see what it introduces into a set of their own project. Over `sealed partial class SetA : +IClosedSet` declared there, `readonly partial record struct OverSet : IValue` was +generated without `Parse`, `TryParse` and `IParsable`, with the base `TypeConverter` rather +than its own, and with a hand-written `Parse` kept silently, while its JSON worked (measured). Over the +same set in a referenced project it got all of them. + +`ClosedSetAspect` runs first now. The value-object aspects see the set's `IParsable`, `Parse` and +`[TypeConverter]` and build the value object's parsing and type converter on them, so a value object +over a set is the same wherever the set is declared: a struct or a record class, over a nested set, +validated through `Create`, ordinal, refusing without quoting the input, CMTK1011 on a hand-written +`Parse` (`ClosedSetWrappingTests`, `BuildOutcomeTests`; with the old order they fail). The closed-set +aspect reads only the set's own declaration (fields, constructors, initializer text, methods, +attributes, interfaces), none of which the value-object aspects introduce, so its output does not +change. + +The alternative was to refuse the declaration with CMTK1005, as a value object over a value object +of its own project is refused. That refusal exists because one aspect class never sees what its own +sibling instances introduce, which no order can change. A closed set is a different aspect class, so +an order fixes it, and refusing a declaration that can be generated in full would have taken a +working use (an identifier that is one of a set's members) for no reason the user could act on. diff --git a/design/adr/0060-value-object-over-same-project-closed-set-refused.md b/design/adr/0060-value-object-over-same-project-closed-set-refused.md deleted file mode 100644 index 6929d24..0000000 --- a/design/adr/0060-value-object-over-same-project-closed-set-refused.md +++ /dev/null @@ -1,19 +0,0 @@ -# 0060. A value object over a closed set of its own project is refused (CMTK1005) - -Date: 2026-10-13 -Status: accepted - -Context: 2026-10-13, the pre-tag review of 2.0.0; [Aspects](../aspects.md), [Closed sets](../closed-sets.md). - -`ClosedSetAspect` runs last in `AspectOrder`, and the value-object aspects do not see what it -introduces. Over `sealed partial class SetA : IClosedSet` declared in the same project, -`readonly partial record struct OverSet : IValue` was generated without `Parse`, `TryParse` -and `IParsable`, and with the base `TypeConverter` rather than its own, while its JSON -worked (measured). Over the same set in a referenced project it got all of them. That is the -layout-dependent surface CMTK1005 already refuses for a value object over a value object of its own -project, and it is refused the same way and with the same id: the fabric answers it, since it reads -the wrapped type, and the message names the set and says to declare it in a referenced project or to -wrap its name. A set from a referenced project stays allowed and generated in full. A refusal can be -lifted later, by ordering the aspects so that the value-object aspects see the set's members; a -silent half-generation could not be taken back. 2.0.0 is unreleased, and closed sets are new in it, -so no 1.x declaration meets the refusal. diff --git a/design/adr/README.md b/design/adr/README.md index e5349ad..94e1fd6 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -66,6 +66,6 @@ per area ([design](../plan.md)) link to the records they rest on. | [0057](0057-hand-written-ordering-refused-in-query.md) | A query that orders by a hand-written `CompareTo` is refused | 2026-10-13 | accepted | | [0058](0058-integer-key-follows-ef-rule.md) | A value-object key is generated wherever EF generates its wrapped type's | 2026-10-13 | accepted | | [0059](0059-text-guard-refuses-what-it-cannot-normalize.md) | The text guards refuse what they cannot normalize, and `AsText()` throws where nothing composes | 2026-10-13 | accepted | -| [0060](0060-value-object-over-same-project-closed-set-refused.md) | A value object over a closed set of its own project is refused (CMTK1005) | 2026-10-13 | accepted | +| [0060](0060-value-object-over-closed-set-generated-in-full.md) | A value object over a closed set is generated in full wherever the set is declared | 2026-10-13 | accepted | | [0061](0061-scope-s-fault-status-applies-where-fault-type-answered.md) | A scope's fault status applies where its fault type is answered | 2026-10-13 | accepted | | [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 6425c3a..9ca488b 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -15,7 +15,7 @@ marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a generic value object, one that derives from another value object, a wrapped type that is not a class, struct or enum, or one that is a value object: itself, one that reaches it again, or - any other, or a closed set of the same project, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares + any other, see below), CMTK1006 (a record class that is not `sealed`: a derived record compares equal only to its own type, which is not value equality), CMTK1007 (the name of the `GetValue`/`ValueOrNull` class is taken, see below), CMTK1008 (a comparison member beside the seam, see below), CMTK1009 (a hand-written constructor, see below), CMTK1022 (an abstract type, @@ -199,12 +199,13 @@ one's rules in its `Create`. Refused rather than made to work: a surface that follows the project layout is the silent failure this repo exists to prevent, and lifting a refusal later breaks nobody, while adding one after 1.0.0 would. - - **A closed set of the same project** (decided 2026-10-13, - [decision 60](adr/0060-value-object-over-same-project-closed-set-refused.md)) is refused the same - way and for the same reason: `ClosedSetAspect` runs last in `AspectOrder`, and over a set of its own - project `OverSet : IValue` lacked `Parse`, `TryParse`, `IParsable` and its own type converter - while its JSON worked; over the same set in a referenced project it had them. The message names the - set and says to declare it in a referenced project or to wrap its name. + - **A closed set is not refused** (decided 2026-10-13, + [decision 60](adr/0060-value-object-over-closed-set-generated-in-full.md)), wherever it is declared. + Over a set of its own project `OverSet : IValue` lacked `Parse`, `TryParse`, `IParsable` and + its own type converter while its JSON worked, because `ClosedSetAspect` ran last, after the + value-object aspects. It is a different aspect class, so ordering fixes what ordering cannot for a + value object over a value object: `ClosedSetAspect` runs first, and the value-object aspects see the + set's `IParsable`, `Parse` and `[TypeConverter]` as they see a referenced set's. - The satellites keep their own refusal ([OpenAPI](openapi.md)): a value object declared by hand, with the interfaces and `GeneratedValueObjectAttribute<,>` the satellites read ([Discovery](discovery.md)), can still wrap itself. - **Every JSON key goes through the wrapped type's own converter**, which knows the type's key @@ -244,8 +245,12 @@ - **Metalama 2026.1.** Aspect state uses `IDurableRef`, which exists in 2026.1. `[Durable]` on the `_value` template placeholder is 2027.0-only and stays out until the upgrade ([decision 4](adr/0004-metalama-2026-1-x-latest-stable.md)). Build each aspect on 2026.1 as it lands, and use no 2027.0-only API. -- **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`. Open point: ordering against a - consumer's own aspects (fabric spike D). +- **`AspectOrder`** lives in `CodoMetis.TypeKit.Generators`: `ClosedSetAspect` first, then + `ValueObjectAspect`, which leaves the state the other value-object aspects read + ([decision 60](adr/0060-value-object-over-closed-set-generated-in-full.md)). The closed-set aspect reads + nothing the value-object aspects introduce: the set's own fields, constructors, initializer text, + methods, attributes and interfaces, none of which they touch, and no member's data type (checked + 2026-10-13). Open point: ordering against a consumer's own aspects (fabric spike D). - **Not in scope:** a `Try`/`Unit` type ([Option and Result](option-result.md) non-goals; an exception enters a result through `Result.Catch`, phase 13), clock-type analyzers, and decimal JSON converters (no aspect needs them, checked in phase 3). The text guards joined phase 13 on 2026-10-13, as string extensions in the diff --git a/design/closed-sets.md b/design/closed-sets.md index bd1dd29..62d0a2a 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -2,7 +2,7 @@ A closed set is a `sealed partial class` implementing `IClosedSet` (or `IFaultSet`), whose members are its `public static readonly` fields of its own type. `ClosedSetAspect`, applied -by the same transitive fabric as the value-object aspects and last in `AspectOrder`, generates the +by the same transitive fabric as the value-object aspects and first in `AspectOrder`, generates the rest. Measured in [spikes/ClosedSets](../spikes/ClosedSets/README.md); shipped in 2.0.0 ([decision 42](adr/0042-2-0-0-next-release-bundles.md), [decision 44](adr/0044-2-0-0-net10-0-metalama.md)). The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). @@ -49,11 +49,15 @@ CMTK1013 to CMTK1021, all Errors, each reported on the declaration it names; the fixes is in the [Generators README](../src/CodoMetis.TypeKit.Generators/README.md). A set the aspect cannot complete is never left half-generated. -A value object over a set declared in the same project is CMTK1005 -([decision 60](adr/0060-value-object-over-same-project-closed-set-refused.md)): `ClosedSetAspect` runs -after the value-object aspects, which do not see the set's `Parse`, `IParsable` and type converter, so -the value object was generated without its own. Over a set of a referenced project it is generated in -full (`ClosedSetTests`). +## Wrapped by a value object + +A value object over a set, `IValue`, is generated in full wherever the set is declared +([decision 60](adr/0060-value-object-over-closed-set-generated-in-full.md)): `ClosedSetAspect` runs +first, so the value-object aspects see the set's `IParsable`, `Parse` and `[TypeConverter]` and build +the value object's parsing and type converter on them. Last, it ran after them, and over a set of the +same project the value object had no parsing and the base `TypeConverter`. Held by +`ClosedSetWrappingTests` (a struct, a record class, a nested set, a validated one, and a set of a +referenced project) and by CMTK1011 still refusing a hand-written `Parse` there. ## Open diff --git a/docs/closed-sets.md b/docs/closed-sets.md index 573cae8..186e4be 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -136,6 +136,10 @@ turns on, reads no type converter: there, bind the name as a `string` and `Parse [Native AOT](native-aot.md#configuration-binding) shows for a value object. A set also implements `IParsable`, explicitly, so generic code parses one through `T.Parse`. +A value object can wrap a set, `IValue`, declared in the same project or another: it +parses, converts and serializes by the member's name, as the set does, and a validated one applies its +`Create` to the member. + ## In logs `ToString()` is the name, so Microsoft.Extensions.Logging logs a member as `Express`. Serilog @@ -175,11 +179,6 @@ A declaration they cannot complete fails the build, naming it; the generators' | writes a generated member by hand, `ToString()` apart | CMTK1011 | | is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | -A value object that wraps a set declared in the same project is refused too (CMTK1005): the set's -parsing and type converter are generated after the value object's, which would be generated without -its own. Declare the set in a referenced project, where a value object over it is generated in full, -or wrap the member's name. - The set's own code can still call its private constructor beside its members, and such an instance has no name: `Name`, `ToString()`, `Match`, JSON and the column throw for it, rather than carry a null ([why](../src/CodoMetis.TypeKit/README.md#an-instance-that-is-not-a-member-of-its-set)). diff --git a/src/CodoMetis.TypeKit.Generators/AspectOrder.cs b/src/CodoMetis.TypeKit.Generators/AspectOrder.cs index 569a5e6..dc79531 100644 --- a/src/CodoMetis.TypeKit.Generators/AspectOrder.cs +++ b/src/CodoMetis.TypeKit.Generators/AspectOrder.cs @@ -1,10 +1,18 @@ using CodoMetis.TypeKit.Generators; using Metalama.Framework.Aspects; -// ValueObjectAspect runs first and leaves ValueObjectAspectState for the others. An aspect -// missing from this list would run in an unspecified order, find no state, and generate nothing. +// ClosedSetAspect runs first, so the value-object aspects see what it introduces into a closed set of +// the same project: a value object over one gets the set's IParsable, Parse and type converter to build +// on, as over a set from a referenced project. Last, it ran after them, and such a value object was +// generated without parsing and with the base TypeConverter (decision 60). One aspect class never sees +// what its own sibling instances introduce, which is why a value object over a value object is refused +// instead; the closed-set aspect reads nothing the value-object aspects introduce. +// Of the value-object aspects, ValueObjectAspect runs first and leaves ValueObjectAspectState for the +// others. An aspect missing from this list would run in an unspecified order, find no state, and +// generate nothing. [assembly: AspectOrder( AspectOrderDirection.CompileTime, + typeof(ClosedSetAspect), typeof(ValueObjectAspect), typeof(ValueObjectContractAspect), typeof(ValueObjectJsonAspect), @@ -14,6 +22,5 @@ typeof(ValueObjectMinMaxValueAspect), typeof(ValueObjectTypeConverterAspect), typeof(ValueObjectConvertibleAspect), - typeof(ValueObjectCompanionAspect), - typeof(ClosedSetAspect) + typeof(ValueObjectCompanionAspect) )] diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 3f94803..3db69fe 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -368,7 +368,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1002 | A struct not declared `readonly`. | | CMTK1003 | More than one marker, such as `IValue, IValue`. | | CMTK1004 | An `IValidatedValue` whose `TSelf` is another type. | -| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value` or as another member the generators introduce (`From`, `TryFrom`, `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, `MinValue`, `MaxValue`, `CompareTo`), derived from another value object, or wrapping `object`, `System.Enum`, an array, a pointer, a nullable type, a generic type (a tuple included), a value object (itself included) or a closed set declared in the same project. Wrap what the other value object wraps instead; declare the set in a referenced project, where the value object gets its parsing and type converter, or wrap its name. | +| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value` or as another member the generators introduce (`From`, `TryFrom`, `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, `MinValue`, `MaxValue`, `CompareTo`), derived from another value object, or wrapping `object`, `System.Enum`, an array, a pointer, a nullable type, a generic type (a tuple included) or a value object (itself included). Wrap what the other value object wraps instead. | | CMTK1006 | A record class not declared `sealed`. | | CMTK1007 | The `{TSelf}Extensions` companion's name is taken by a declared type or by another value object's companion. | | CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index 9bb23ac..2107ba9 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -14,27 +14,26 @@ namespace CodoMetis.TypeKit.Generators; internal sealed partial class ValueObjectAspect : TypeAspect { private readonly string? _companionClassNameOwner; - private readonly string? _wrappedTypeRefusal; + private readonly string? _wrappedValueObjectRefusal; /// /// What already owns the name of the companion class, as /// answered it in the fabric, or when the name is free. /// - /// + /// /// Why the value object cannot wrap what it wraps, as - /// answered it in the fabric, or - /// when what it wraps is neither a value object nor a closed set of the - /// same project. + /// answered it in the fabric, or + /// when what it wraps is not a value object. /// - public ValueObjectAspect(string? companionClassNameOwner, string? wrappedTypeRefusal) + public ValueObjectAspect(string? companionClassNameOwner, string? wrappedValueObjectRefusal) { _companionClassNameOwner = companionClassNameOwner; - _wrappedTypeRefusal = wrappedTypeRefusal; + _wrappedValueObjectRefusal = wrappedValueObjectRefusal; } public override void BuildAspect(IAspectBuilder builder) { - if (!TryResolve(builder, _wrappedTypeRefusal, out var kind, out var valueType)) + if (!TryResolve(builder, _wrappedValueObjectRefusal, out var kind, out var valueType)) { builder.SkipAspect(); return; @@ -110,7 +109,7 @@ public override void BuildAspect(IAspectBuilder builder) /// The one marker, the kind and the wrapped type, or an error for a declaration that cannot be /// generated. Generating it anyway would fail later with a far less useful message. /// - private static bool TryResolve(IAspectBuilder builder, string? wrappedTypeRefusal, out ValueObjectKind kind, [NotNullWhen(true)] out INamedType? valueType) + private static bool TryResolve(IAspectBuilder builder, string? wrappedValueObjectRefusal, out ValueObjectKind kind, [NotNullWhen(true)] out INamedType? valueType) { var target = builder.Target; var markers = ValueObjectTypes.Markers(target); @@ -256,9 +255,9 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp // Before the field is introduced: over a struct that wraps itself it has no layout (CS0523), // which Metalama reports as a bug in this aspect. - if (wrappedTypeRefusal is not null) + if (wrappedValueObjectRefusal is not null) { - builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, wrappedTypeRefusal))); + builder.Diagnostics.Report(UnsupportedValueObject.WithArguments((target, wrappedValueObjectRefusal))); return false; } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs index 965e000..6ceba7f 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFabric.cs @@ -23,8 +23,7 @@ namespace CodoMetis.TypeKit.Generators; /// /// /// The aspect is handed what owns its companion class's name, and whether what it wraps is a value -/// object or a closed set of the same project (). -/// Both read types other than its +/// object (). Both read types other than its /// target, so they are computed here, where nothing has been introduced yet, rather than in an /// aspect. The factory below still runs in parallel on one code model, so they find a type by /// enumerating a collection, never by OfName, whose index is not safe for parallel callers @@ -39,7 +38,7 @@ public override void AmendProject(IProjectAmender amender) .Where(ValueObjectTypes.IsValueObject) .AddAspect(type => new ValueObjectAspect( CompanionClass.NameOwner(type), - ValueObjectTypes.WrappedTypeRefusal(type))); + ValueObjectTypes.WrappedValueObjectRefusal(type))); // An abstract type gets no aspect, so it is refused here, where it is selected. amender.SelectTypes(includeNestedTypes: true) diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs index 14dff30..bbe5364 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectTypes.cs @@ -49,36 +49,10 @@ public static bool IsAbstractValueObject(INamedType type) => && !type.IsStatic && Markers(type).Count > 0; - /// - /// Why cannot be generated over what it wraps, read from types - /// other than itself: a value object () or a closed set of - /// the same project (). when it - /// wraps neither. Called from the fabric only, since it reads types other than the aspect's target - /// (). - /// - public static string? WrappedTypeRefusal(INamedType valueObject) => - WrappedValueObjectRefusal(valueObject) ?? SameProjectClosedSetRefusal(valueObject); - - /// - /// Why cannot be generated over the closed set it wraps, or - /// when what it wraps is no closed set declared in the same project. - /// - /// - /// The closed-set aspect runs after the value-object aspects (AspectOrder), and they do not - /// see what it introduces: over a set of its own project a value object was generated without - /// Parse, TryParse, IParsable and its own type converter, while its JSON worked, - /// and over the same set in a referenced project it got all of them (measured 2026-10-13). A - /// surface that follows the project layout is refused, as for a value object over a value object. - /// - private static string? SameProjectClosedSetRefusal(INamedType valueObject) => - WrappedNamedType(valueObject) is { } set && ClosedSetTypes.IsClosedSet(set) && !set.DeclaringAssembly.IsExternal - ? $"it wraps '{set.ToDisplayString()}', a closed set declared in the same project, whose parsing and type converter are generated after the value object's and would be missing from it; " - + "declare the set in a referenced project, or wrap its name ('string') instead" - : null; - /// /// Why cannot be generated over what it wraps, or - /// when that is not a value object. + /// when that is not a value object. Called from the fabric only, since it + /// reads types other than the aspect's target (). /// /// /// @@ -97,7 +71,7 @@ public static bool IsAbstractValueObject(INamedType type) => /// Create. /// /// - private static string? WrappedValueObjectRefusal(INamedType valueObject) + public static string? WrappedValueObjectRefusal(INamedType valueObject) { List chain = [valueObject]; var next = WrappedNamedType(valueObject); diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 8f6c117..1c122af 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -40,8 +40,6 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1005", "StructCycleOne")] [InlineData("CMTK1005", "StructCycleTwo")] [InlineData("CMTK1005", "WrapsAValueObject")] - [InlineData("CMTK1005", "WrapsASet")] - [InlineData("CMTK1005", "ValidatedOverASet")] [InlineData("CMTK1005", "ListBacked")] [InlineData("CMTK1005", "ImmutableArrayBacked")] [InlineData("CMTK1005", "PairBacked")] @@ -83,6 +81,7 @@ public sealed partial class BuildOutcomeTests(BuildOutcomeTests.Consumer consume [InlineData("CMTK1011", "HandWrittenFrom")] [InlineData("CMTK1011", "HandWrittenValue")] [InlineData("CMTK1011", "HandWrittenParse")] + [InlineData("CMTK1011", "HandWrittenParseOverASet")] [InlineData("CMTK1011", "HandWrittenFormat")] [InlineData("CMTK1011", "HandWrittenMinValue")] [InlineData("CMTK1011", "HandWrittenJsonConverter")] @@ -203,19 +202,6 @@ public void CMTK0012_reports_a_call_by_position_with_the_generators_names_in_the public void A_value_object_over_a_value_object_is_refused_with_what_it_reaches(string type, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith($"'{type}'") && error.Message.Contains(reason), $"CMTK1005 on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); - /// - /// The closed-set aspect runs after the value-object aspects, which do not see what it introduces: - /// over a set of its own project a value object was generated without Parse, IParsable - /// and its own type converter, while over the same set in a referenced project it had them - /// ( holds that case). The refusal names the set and both ways out. - /// - [Theory] - [InlineData("WrapsASet")] - [InlineData("ValidatedOverASet")] - public void A_value_object_over_a_closed_set_of_its_own_project_is_refused_with_the_way_out(string type) => - consumer.Errors.ShouldContain(error => error.Id == "CMTK1005" && error.Message.StartsWith($"'{type}' cannot be generated as a value object: it wraps 'SetToWrap', a closed set declared in the same project") - && error.Message.Contains("declare the set in a referenced project, or wrap its name ('string') instead"), $"CMTK1005 on {type}. The build reported:{Environment.NewLine}{consumer.Output}"); - /// /// A generic wrapped type failed inside the generated code (LAMA0611). The refusal says why, since /// the type is often a perfectly good type: its equality is not guaranteed to be value equality. @@ -305,6 +291,7 @@ public void A_part_a_source_generator_adds_does_not_count() => [InlineData("HandWrittenFrom", "HandWrittenFrom.From(string)")] [InlineData("HandWrittenValue", "HandWrittenValue.Value")] [InlineData("HandWrittenParse", "HandWrittenParse.Parse(string, IFormatProvider?)")] + [InlineData("HandWrittenParseOverASet", "HandWrittenParseOverASet.Parse(string, IFormatProvider?)")] [InlineData("HandWrittenFormat", "HandWrittenFormat.ToString(string?, IFormatProvider?)")] [InlineData("HandWrittenMinValue", "HandWrittenMinValue.MinValue")] [InlineData("HandWrittenJsonConverter", "the attribute JsonConverter")] @@ -661,15 +648,13 @@ public sealed partial record CycleTwo : IValue; // from the same project, parsing, comparison and the type converter were silently missing. public readonly partial record struct WrapsAValueObject : IValue; - // Over a closed set of the same project, the value-object aspects ran before the set's and - // missed its parsing and type converter: no Parse, no IParsable, the base TypeConverter. + // Over a closed set of this project the value object parses through the set, which it sees + // because ClosedSetAspect runs first; a Parse of its own was kept silently while it did not. public sealed partial class SetToWrap : IClosedSet { public static readonly SetToWrap A = new(); } - public readonly partial record struct WrapsASet : IValue; - - public sealed partial record ValidatedOverASet : IValidatedValue + public readonly partial record struct HandWrittenParseOverASet : IValue { - public static Result Create(SetToWrap value) => Result.Error(Fault.Refused); + public static HandWrittenParseOverASet Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); } // Companion classes are named after the whole nesting chain, so these two coexist. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs index 62a9974..3788b60 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetTests.cs @@ -4,16 +4,9 @@ using CodoMetis.TypeKit.ClosedSets; using CodoMetis.TypeKit.CompilerServices; using CodoMetis.TypeKit.Generators.Probes; -using CodoMetis.TypeKit.ValueObjects; namespace CodoMetis.TypeKit.Generators.Tests; -/// -/// A value object over a closed set of a referenced project, which is generated in full: over a set of -/// its own project it is refused (CMTK1005, ). -/// -public readonly partial record struct OverProbeStatus : IValue; - /// A set whose names differ from their upper and lower cases by an i, which tr-TR does not case as English does. public sealed partial class DocumentKind : IClosedSet { @@ -129,25 +122,6 @@ public void TryParse_and_Parse_find_a_member_by_name_and_refuse_without_quoting( refusal.Message.ShouldContain("#a-closed-set-has-no-member-of-that-name"); } - /// - /// Over a set of a referenced project the value-object aspects see what the closed-set aspect - /// generated there, so the value object parses and converts through the set's name. A guard against - /// the same-project refusal reaching further than the project it is about. - /// - [Fact] - public void A_value_object_over_a_set_of_a_referenced_project_parses_and_converts_by_name() - { - OverProbeStatus.Parse("Shipped", null).Value.ShouldBeSameAs(ProbeStatus.Shipped); - OverProbeStatus.TryParse("Nope", null, out _).ShouldBeFalse(); - - var converter = TypeDescriptor.GetConverter(typeof(OverProbeStatus)); - converter.GetType().ShouldNotBe(typeof(TypeConverter), "the value object's own type converter, not the base one"); - converter.ConvertFromInvariantString("Open").ShouldBe(OverProbeStatus.From(ProbeStatus.Open)); - converter.ConvertToInvariantString(OverProbeStatus.From(ProbeStatus.Cancelled)).ShouldBe("Cancelled"); - - JsonSerializer.Serialize(OverProbeStatus.From(ProbeStatus.Open)).ShouldBe("\"Open\""); - } - /// /// A header or a query string a client typed: the ordinal parse stays the default, and the /// ignore-case one is opted into at the call. Unknown names are refused as the ordinal one refuses them. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetWrappingTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetWrappingTests.cs new file mode 100644 index 0000000..2275ca3 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ClosedSetWrappingTests.cs @@ -0,0 +1,180 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// A closed set of this project, which the value objects below wrap. +public sealed partial class LocalStatus : IClosedSet +{ + public static readonly LocalStatus Open = new(); + public static readonly LocalStatus Shipped = new(); + public static readonly LocalStatus Cancelled = new(); +} + +/// A nested closed set of this project. +public static class LocalOrder +{ + public sealed partial class Stage : IClosedSet + { + public static readonly Stage Draft = new(); + public static readonly Stage Shipped = new(); + } +} + +/// A struct value object over a set of its own project. +public readonly partial record struct StatusOfOrder : IValue; + +/// A record-class value object over a set of its own project. +public sealed partial record StatusLabel : IValue; + +/// A value object over a nested set of its own project. +public readonly partial record struct StageOfOrder : IValue; + +public enum ShippableFault { Cancelled } + +/// A validated value object over a set of its own project: a cancelled order ships nothing. +public readonly partial record struct ShippableStatus : IValidatedValue +{ + public static Result Create(LocalStatus value) => + ReferenceEquals(value, LocalStatus.Cancelled) ? Result.Error(ShippableFault.Cancelled) : new ShippableStatus(value); +} + +/// A value object over a closed set of a referenced project. +public readonly partial record struct OverProbeStatus : IValue; + +/// +/// A value object over a closed set is generated in full wherever the set is declared (decision 60). +/// ClosedSetAspect ran last, after the value-object aspects, which then did not see the set's +/// IParsable, Parse and type converter: over a set of its own project a value object had no +/// parsing and the base TypeConverter, while over the same set in a referenced project it had both. +/// It runs first now. +/// +/// +/// Everything that would be missing is reached through reflection or a generic constraint checked at +/// run time, so with the old order these fail rather than stop the project compiling. +/// +public sealed class ClosedSetWrappingTests +{ + public static TheoryData Wrappers => + [ + (typeof(StatusOfOrder), "Shipped", LocalStatus.Shipped), + (typeof(StatusLabel), "Shipped", LocalStatus.Shipped), + (typeof(StageOfOrder), "Shipped", LocalOrder.Stage.Shipped), + (typeof(ShippableStatus), "Shipped", LocalStatus.Shipped), + (typeof(OverProbeStatus), "Shipped", ProbeStatus.Shipped), + ]; + + [Theory] + [MemberData(nameof(Wrappers))] + public void It_parses_the_members_name_through_IParsable_and_its_own_Parse(Type type, string name, object member) + { + Value(Parse(type, name)).ShouldBeSameAs(member); + Value(StaticParse(type, name)).ShouldBeSameAs(member); + TryParse(type, name, out var parsed).ShouldBeTrue(); + Value(parsed!).ShouldBeSameAs(member); + + // As the set itself parses: ordinally, and with no span parsing, which the set does not have. + TryParse(type, name.ToUpperInvariant(), out _).ShouldBeFalse(); + type.GetInterfaces().ShouldNotContain(implemented => implemented.IsGenericType && implemented.GetGenericTypeDefinition() == typeof(ISpanParsable<>)); + } + + [Theory] + [MemberData(nameof(Wrappers))] + public void Its_own_type_converter_reads_and_writes_the_members_name(Type type, string name, object member) + { + var converter = TypeDescriptor.GetConverter(type); + + converter.GetType().ShouldNotBe(typeof(TypeConverter), "the value object's own type converter, not the base one"); + var converted = converter.ConvertFromInvariantString(name).ShouldNotBeNull(); + Value(converted).ShouldBeSameAs(member); + converter.ConvertToInvariantString(converted).ShouldBe(name); + } + + [Theory] + [MemberData(nameof(Wrappers))] + public void Its_JSON_is_the_members_name_as_a_value_and_as_a_key(Type type, string name, object member) + { + var json = $"\"{name}\""; + var read = JsonSerializer.Deserialize(json, type).ShouldNotBeNull(); + Value(read).ShouldBeSameAs(member); + JsonSerializer.Serialize(read, type).ShouldBe(json); + + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(type, typeof(int)); + var keys = JsonSerializer.Deserialize($"{{{json}:1}}", dictionaryType).ShouldNotBeNull(); + JsonSerializer.Serialize(keys, dictionaryType).ShouldBe($"{{{json}:1}}"); + } + + /// Every way in applies Create: a validated value object refuses the member its rules refuse. + [Fact] + public void Every_way_in_applies_Create() + { + var parse = Should.Throw(() => Parse(typeof(ShippableStatus), "Cancelled")); + parse.Message.ShouldContain(nameof(ShippableFault.Cancelled)); + + Should.Throw(() => StaticParse(typeof(ShippableStatus), "Cancelled")); + TryParse(typeof(ShippableStatus), "Cancelled", out _).ShouldBeFalse(); + Should.Throw(() => TypeDescriptor.GetConverter(typeof(ShippableStatus)).ConvertFromInvariantString("Cancelled")); + Should.Throw(() => JsonSerializer.Deserialize("\"Cancelled\"")); + } + + /// A name that is no member is refused without quoting it, by every way in. + [Theory] + [MemberData(nameof(Wrappers))] + public void A_name_that_is_no_member_is_refused_without_quoting_it(Type type, string name, object member) + { + _ = (name, member); + const string secret = "Secret-Not-A-Member"; + + var parse = Should.Throw(() => Parse(type, secret)); + var converter = Should.Throw(() => TypeDescriptor.GetConverter(type).ConvertFromInvariantString(secret)); + var json = Should.Throw(() => JsonSerializer.Deserialize($"\"{secret}\"", type)); + + foreach (var refusal in new[] { parse, converter, json }) + { + for (Exception? exception = refusal; exception is not null; exception = exception.InnerException) + exception.Message.ShouldNotContain(secret); + } + + parse.Message.ShouldStartWith($"{type.Name} could not read the input as "); + } + + private static object Value(object valueObject) => + valueObject.GetType().GetProperty("Value")!.GetValue(valueObject)!; + + /// T.Parse in generic code, constrained to IParsable<T>; the constraint is checked when the method is made. + private static object Parse(Type type, string text) => + Unwrapped(() => typeof(ClosedSetWrappingTests).GetMethod(nameof(ParseAs), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type).Invoke(null, [text])!); + + private static bool TryParse(Type type, string text, out object? result) + { + object?[] arguments = [text, null]; + var parsed = (bool)Unwrapped(() => typeof(ClosedSetWrappingTests).GetMethod(nameof(TryParseAs), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type).Invoke(null, arguments)!); + result = arguments[1]; + return parsed; + } + + /// The value object's own public Parse(string, IFormatProvider?). + private static object StaticParse(Type type, string text) => + Unwrapped(() => type.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, [typeof(string), typeof(IFormatProvider)]).ShouldNotBeNull($"{type.Name} has no Parse(string, IFormatProvider?)").Invoke(null, [text, null])!); + + private static T ParseAs(string text) where T : IParsable => T.Parse(text, null); + + private static bool TryParseAs(string text, out T? result) where T : IParsable => T.TryParse(text, null, out result); + + private static T Unwrapped(Func call) + { + try + { + return call(); + } + catch (TargetInvocationException exception) when (exception.InnerException is { } inner) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(inner).Throw(); + throw; + } + } +} From a4e2fd7452774cdecacf845997d74e451668655a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:19:46 +0200 Subject: [PATCH 117/147] fix: an exception from Create leaves TryParse as it leaves Parse, whatever the wrapped type parses through Over a wrapped type with no TryParse of its own (a type converter, a static Parse, a string constructor) the generated TryParse wrapped the inner parse and Create in a bare catch, so an exception Create threw became false, while Parse, and TryParse over a type with a TryParse, let it through. It catches only the FormatException the inner parse throws for unreadable text, around the inner parse alone. Co-Authored-By: Claude Opus 5.5 --- design/aspects.md | 6 ++ src/CodoMetis.TypeKit.Generators/README.md | 2 + .../ValueObjectParsableAspect.Templates.cs | 19 +++-- .../ThrowingCreateTests.cs | 72 +++++++++++++++++++ 4 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/ThrowingCreateTests.cs diff --git a/design/aspects.md b/design/aspects.md index 9ca488b..5982a9b 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -109,6 +109,12 @@ An overflow is a `FormatException` too. `UnreadableInputTests` feeds a secret through every strategy and entry point and searches the whole exception chain. Out of reach: `JsonException.Path`, which the serializer fills with an unreadable dictionary key, and ASP.NET's own binding messages. +- **`TryParse` catches the wrapped type's refusal alone (decided 2026-10-13).** Over a type with no + `TryParse` of its own (a type converter, a static `Parse`, a string constructor) the generated + `TryParse` wrapped the inner parse and `Create` in a bare `catch`, so an exception `Create` threw was + a `false` there, while `Parse`, and `TryParse` over an `int`, let it through. It catches only the + `FormatException` the inner parse throws for unreadable text (`GeneratedParsing.Unreadable`), around + the inner parse alone (`ThrowingCreateTests`). - **`Uri` parses relative or absolute (decided 2026-09-28)**, as JSON and `UriTypeConverter` read it: `new Uri(s)` made "/orders/7" a `file://` URI on Unix and threw for "orders/7". - **A hand-written `ToString()` is a seam (decided 2026-09-28, [decision 28](adr/0028-seams-declared-everything-else-refused.md)).** It was replaced diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 3db69fe..c35d9ef 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -342,6 +342,8 @@ provider-less `Parse` ignore it. Text the wrapped type cannot read is a `FormatException` naming the value object and the wrapped type ("Quantity could not read the input as Int32."), an overflow included, with no inner exception: the wrapped type's own message quotes the input. A refusal by `Create` names the fault instead. +`TryParse` answers `false` for both, and lets anything else through as `Parse` does: an exception +`Create` throws is a bug to see, not text to refuse. ## Comparison diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs index a48d84f..84acc05 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectParsableAspect.Templates.cs @@ -42,18 +42,17 @@ public static bool TryParseTemplate( return (bool)ExpressionFactory.Parse($"(s is not null && {tag.TryFromText}(s, out result))", TypeFactory.GetType(SpecialType.Boolean), false).Value!; } - // The remaining strategies have no TryParse of their own: their inner parse throws. + // The remaining strategies have no TryParse of their own: their inner parse throws, and what it + // throws for text the wrapped type cannot read is GeneratedParsing.Unreadable, a FormatException. + // Only that is caught, around the inner parse alone: a bare catch around the wrapping as well + // made an exception from Create a false, where Parse, and TryParse over a type with a TryParse + // of its own, let it through. if (s is null) return false; - try - { - return (bool)ExpressionFactory.Parse($"{tag.TryFromText}({tag.InnerParse()}, out result)", TypeFactory.GetType(SpecialType.Boolean), false).Value!; - } - catch - { - result = meta.Default(tag.ValueObjectType); - return false; - } + meta.InsertStatement($"{ValueObjectTypes.SourceName(tag.ValueType)} __inner;"); + meta.InsertStatement($"try {{ __inner = {tag.InnerParse()}; }} catch (global::System.FormatException) {{ return false; }}"); + + return (bool)ExpressionFactory.Parse($"{tag.TryFromText}(__inner, out result)", TypeFactory.GetType(SpecialType.Boolean), false).Value!; } [Template] diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ThrowingCreateTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ThrowingCreateTests.cs new file mode 100644 index 0000000..dab0462 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/ThrowingCreateTests.cs @@ -0,0 +1,72 @@ +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; +using NodaTime; + +namespace CodoMetis.TypeKit.Generators.Tests; + +public enum ThrowingFault { Refused } + +/// Parse strategy: the wrapped type's static Parse(string). +public readonly partial record struct ThrowingSku : IValidatedValue +{ + public static Result Create(ProbeSkuText value) => + value.Text == ThrowingCreateTests.Sku ? throw new InvalidOperationException("Create threw.") : new ThrowingSku(value); +} + +/// Parse strategy: the wrapped type's string constructor. +public readonly partial record struct ThrowingIsbn : IValidatedValue +{ + public static Result Create(ProbeIsbnText value) => + value.Text == ThrowingCreateTests.Isbn ? throw new InvalidOperationException("Create threw.") : new ThrowingIsbn(value); +} + +/// Parse strategy: the wrapped type's [TypeConverter]. +public readonly partial record struct ThrowingDate : IValidatedValue +{ + public static Result Create(LocalDate value) => + value == new LocalDate(2026, 10, 13) ? throw new InvalidOperationException("Create threw.") : new ThrowingDate(value); +} + +/// Parse strategy: the wrapped type's own TryParse, which already let it through. +public readonly partial record struct ThrowingCount : IValidatedValue +{ + public static Result Create(int value) => + value == 13 ? throw new InvalidOperationException("Create threw.") : new ThrowingCount(value); +} + +/// +/// An exception Create throws is not a refusal, and leaves TryParse as it leaves +/// Parse, whatever the wrapped type parses through. Over a wrapped type without a TryParse +/// of its own (a type converter, a static Parse, a string constructor) a bare catch around +/// the inner parse and the wrapping made it a false, while over an int it was thrown. Only +/// the wrapped type's refusal of the text is caught now. +/// +public sealed class ThrowingCreateTests +{ + public const string Sku = "THROW00"; + public const string Isbn = "THROW00000000"; + + public static TheoryData Strategies => ["static Parse", "string constructor", "type converter", "TryParse"]; + + private static readonly Dictionary Parse, Func TryParse, string Throwing, string Fine, string Unreadable)> Cases = new() + { + ["static Parse"] = (text => ThrowingSku.Parse(text, null), text => ThrowingSku.TryParse(text, null, out _), Sku, "ABC1234", "short"), + ["string constructor"] = (text => ThrowingIsbn.Parse(text, null), text => ThrowingIsbn.TryParse(text, null, out _), Isbn, "9780000000001", "short"), + ["type converter"] = (text => ThrowingDate.Parse(text, null), text => ThrowingDate.TryParse(text, null, out _), "2026-10-13", "2026-10-14", "not a date"), + ["TryParse"] = (text => ThrowingCount.Parse(text, null), text => ThrowingCount.TryParse(text, null, out _), "13", "14", "x"), + }; + + [Theory] + [MemberData(nameof(Strategies))] + public void An_exception_from_Create_leaves_TryParse_as_it_leaves_Parse(string strategy) + { + var (parse, tryParse, throwing, fine, unreadable) = Cases[strategy]; + + Should.Throw(() => parse(throwing)).Message.ShouldBe("Create threw."); + Should.Throw(() => tryParse(throwing)).Message.ShouldBe("Create threw."); + + tryParse(fine).ShouldBeTrue(); + tryParse(unreadable).ShouldBeFalse("text the wrapped type cannot read is still a false"); + Should.Throw(() => parse(unreadable)); + } +} From bef5770d02117bcab4905ed5f3dc16e17f013eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:24:06 +0200 Subject: [PATCH 118/147] fix: a default instance over a reference type formats as an empty string everywhere A struct value object's default instance holds null over a reference type. Its ToString() over a string was already "", but over a Uri, a Version or an IPAddress ToString(), interpolation, IFormattable, both TryFormats and the type converter threw NullReferenceException, and so did Convert.ToString and IConvertible over a string. Every generated format answers "" now, the span formats writing nothing, and IConvertible over a reference type goes through Convert with the value as an object: the value's own conversion, and for null Convert's ("", a number's default, TypeCode.Empty). Value types are unchanged. Co-Authored-By: Claude Opus 5.5 --- design/aspects.md | 9 ++ src/CodoMetis.TypeKit.Generators/README.md | 3 + .../ValueObjectConvertibleAspect.Templates.cs | 39 +++++-- .../ValueObjectConvertibleAspect.cs | 13 ++- .../ValueObjectFormattableAspect.Templates.cs | 9 +- .../ValueObjectFormattableAspect.cs | 9 ++ .../DefaultInstanceFormattingTests.cs | 101 ++++++++++++++++++ 7 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/DefaultInstanceFormattingTests.cs diff --git a/design/aspects.md b/design/aspects.md index 5982a9b..9e56447 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -90,6 +90,15 @@ `JsonSerializer.Serialize`/`Deserialize` per value cost 35–40% (measured). The plan is kept per converter instance for the options it last saw; a static plan per wrapped type cost 8% on writes. The enum's built-in converter is made once per plan: made per call it allocated 4.7 KB per value. +- **A default instance over a reference type formats as `""`** (decided 2026-10-13). It is not supposed + to exist (CMTK0001), but EF Core, MVC and serializers make one, and it holds null. Its `ToString()` + over a string was already `""`; over a `Uri`, a `Version` or an `IPAddress` it, interpolation, + `IFormattable`, both `TryFormat`s and the type converter threw `NullReferenceException`, as did + `Convert.ToString` and `IConvertible` over a string. Every generated format answers `""` now (the + span formats write nothing), and `IConvertible` over a reference type goes through `Convert` with the + value as an object, which is the value's own conversion and, for null, Convert's: `""`, a number's + default, `TypeCode.Empty` (`DefaultInstanceFormattingTests`). Harmless rather than loud, because a + default instance already printed `""` over a string and the BCL prints a null that way. - **Formatting and comparison never box** (decided 2026-09-28). The wrapped type's `ToString`, `TryFormat` and UTF-8 `TryFormat` are called through `GeneratedFormatting`, a constrained generic call that also reaches an explicit implementation, and comparison through `Comparer.Default`, diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index c35d9ef..9f55ee1 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -345,6 +345,9 @@ the wrapped type's own message quotes the input. A refusal by `Create` names the `TryParse` answers `false` for both, and lets anything else through as `Parse` does: an exception `Create` throws is a bug to see, not text to refuse. +A default instance, which CMTK0001 keeps out of your own code but EF Core, MVC and serializers can +make, formats as `""` over a reference type, as it always did over a string. + ## Comparison `CompareTo` and the operators follow the wrapped type's ordering, ordinal for a string. To order diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs index 78c7d08..073a294 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.Templates.cs @@ -6,6 +6,12 @@ namespace CodoMetis.TypeKit.Generators; // A null provider means the invariant culture, as in the generated formatting and parsing: // Convert.ToString(amount) passes null. +// +// Over a reference type (a string) every member goes through System.Convert with the wrapped value as +// an object, which for a value is the value's own IConvertible and for the null a default instance holds +// is Convert's answer for null: "" for text, the target type's default for a number. Cast to +// IConvertible, that null threw NullReferenceException, where ToString() answered "" (decided +// 2026-10-13). A struct is cast, as before: it is never null, and Convert would box it the same way. internal sealed partial class ValueObjectConvertibleAspect { // C# text: in a template, CultureInfo.InvariantCulture beside a run-time operand of ?? counts as @@ -13,8 +19,13 @@ internal sealed partial class ValueObjectConvertibleAspect private const string Provider = "(provider ?? global::System.Globalization.CultureInfo.InvariantCulture)"; [Template] - public TypeCode GetTypeCodeTemplate() => - ((IConvertible)meta.This.Value).GetTypeCode(); + public TypeCode GetTypeCodeTemplate([CompileTime] bool referenceType) + { + if (referenceType) + return (TypeCode)ExpressionFactory.Parse("global::System.Convert.GetTypeCode((object?)this.Value)", TypeFactory.GetType(typeof(TypeCode)), false).Value!; + + return ((IConvertible)meta.This.Value).GetTypeCode(); + } /// /// The wrapped value's conversion, except the identity: Convert.ChangeType(q, typeof(Quantity)) @@ -23,7 +34,7 @@ public TypeCode GetTypeCodeTemplate() => /// 's, so it honours a hand-written ToString(). /// [Template] - public object ToTypeTemplate(Type conversionType, IFormatProvider? provider, [CompileTime] INamedType valueObjectType, [CompileTime] bool declaresToString) + public object ToTypeTemplate(Type conversionType, IFormatProvider? provider, [CompileTime] INamedType valueObjectType, [CompileTime] bool declaresToString, [CompileTime] bool referenceType) { string valueObject = meta.CompileTime(ValueObjectTypes.SourceName(valueObjectType)); @@ -35,16 +46,29 @@ public object ToTypeTemplate(Type conversionType, IFormatProvider? provider, [Co if (conversionType == typeof(string)) return meta.This.ToString(); } + else if (referenceType) + { + // Text is the same as IConvertible.ToString(provider)'s, "" for a default instance, where + // Convert.ChangeType answers null for it. + if (conversionType == typeof(string)) + return ExpressionFactory.Parse($"global::System.Convert.ToString((object?)this.Value, {Provider})", TypeFactory.GetType(SpecialType.Object), false).Value!; + } + + if (referenceType) + return ExpressionFactory.Parse($"global::System.Convert.ChangeType((object?)this.Value, conversionType, {Provider})!", TypeFactory.GetType(SpecialType.Object), false).Value!; return ExpressionFactory.Parse($"((global::System.IConvertible)this.Value).ToType(conversionType, {Provider})", TypeFactory.GetType(SpecialType.Object), false).Value!; } [Template] - public string ToStringTemplate(IFormatProvider? provider, [CompileTime] bool declaresToString) + public string ToStringTemplate(IFormatProvider? provider, [CompileTime] bool declaresToString, [CompileTime] bool referenceType) { if (declaresToString) return meta.This.ToString(); + if (referenceType) + return (string)ExpressionFactory.Parse($"global::System.Convert.ToString((object?)this.Value, {Provider})", TypeFactory.GetType(SpecialType.String), false).Value!; + return (string)ExpressionFactory.Parse($"((global::System.IConvertible)this.Value).ToString({Provider})", TypeFactory.GetType(SpecialType.String), false).Value!; } @@ -52,11 +76,14 @@ public string ToStringTemplate(IFormatProvider? provider, [CompileTime] bool dec public dynamic TypedConversionTemplate( IFormatProvider? provider, [CompileTime] string methodName, - [CompileTime] IType returnType + [CompileTime] IType returnType, + [CompileTime] bool referenceType ) { return ExpressionFactory.Parse( - $"((global::System.IConvertible)this.Value).{methodName}({Provider})", + referenceType + ? $"global::System.Convert.{methodName}((object?)this.Value, {Provider})" + : $"((global::System.IConvertible)this.Value).{methodName}({Provider})", returnType, false ).Value!; diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs index 9f68e81..7874edd 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectConvertibleAspect.cs @@ -42,6 +42,9 @@ public override void BuildAspect(IAspectBuilder builder) var interfaceType = builder.ImplementInterface(typeof(IConvertible), OverrideStrategy.Ignore); + // A default instance of a struct value object over a reference type holds null. + var referenceType = valueType.IsReferenceType == true; + // GetTypeCode — unique shape, dedicated template. interfaceType.ExplicitMembers.IntroduceMethod( nameof(GetTypeCodeTemplate), @@ -51,7 +54,8 @@ public override void BuildAspect(IAspectBuilder builder) { m.Name = nameof(IConvertible.GetTypeCode); m.AddAttribute(CodeAnnotations.CompilerGenerated); - } + }, + args: new { referenceType } ); // ToType — universal escape hatch, dedicated template. @@ -64,7 +68,7 @@ public override void BuildAspect(IAspectBuilder builder) m.Name = nameof(IConvertible.ToType); m.AddAttribute(CodeAnnotations.CompilerGenerated); }, - args: new { valueObjectType = builder.Target, declaresToString = state.DeclaresToString } + args: new { valueObjectType = builder.Target, declaresToString = state.DeclaresToString, referenceType } ); // ToString(IFormatProvider): the wrapped value's text, or the value object's own ToString() @@ -79,7 +83,7 @@ public override void BuildAspect(IAspectBuilder builder) m.Name = nameof(IConvertible.ToString); m.AddAttribute(CodeAnnotations.CompilerGenerated); }, - args: new { declaresToString = state.DeclaresToString } + args: new { declaresToString = state.DeclaresToString, referenceType } ); // One template, introduced once per typed conversion with its name and return type. @@ -101,7 +105,8 @@ public override void BuildAspect(IAspectBuilder builder) args: new { methodName = capturedMethodName, - returnType = capturedReturnType.ToNamedType() + returnType = capturedReturnType.ToNamedType(), + referenceType } ); } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs index 79bfdbc..339c2b4 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.Templates.cs @@ -56,9 +56,12 @@ public bool TryFormatTemplate( IFormatProvider? provider ) { + var tag = (FormattableImplementationArguments)meta.Tags.Source!; + charsWritten = 0; - return (bool)Formatted($"{GeneratedFormatting}.TryFormat(this.Value, destination, out charsWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; + // A default instance over a reference type holds null, whose text is "" (WrappedText). + return (bool)Formatted((tag.ValueType.IsReferenceType == true ? "this.Value is null || " : "") + $"{GeneratedFormatting}.TryFormat(this.Value, destination, out charsWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; } // Satisfies IUtf8SpanFormattable.TryFormat — only emitted when T : IUtf8SpanFormattable. @@ -70,8 +73,10 @@ public bool TryFormatUtf8Template( IFormatProvider? provider ) { + var tag = (FormattableImplementationArguments)meta.Tags.Source!; + bytesWritten = 0; - return (bool)Formatted($"{GeneratedFormatting}.TryFormatUtf8(this.Value, utf8Destination, out bytesWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; + return (bool)Formatted((tag.ValueType.IsReferenceType == true ? "this.Value is null || " : "") + $"{GeneratedFormatting}.TryFormatUtf8(this.Value, utf8Destination, out bytesWritten, format, provider {InvariantIfNull})", SpecialType.Boolean).Value!; } } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs index 8593c0a..eccc0c1 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectFormattableAspect.cs @@ -122,12 +122,21 @@ public override void BuildAspect(IAspectBuilder builder) /// converter writes the wrapped value through it too, so it keeps doing so for a value object that /// declares its own ToString() and gets no formatting interfaces. /// + /// + /// A default instance of a struct value object over a reference type holds null, and its text is + /// "", as interpolating that null gives and as a string's always was: formatting it through + /// the wrapped type threw NullReferenceException over a Uri, a Version or an + /// IPAddress (decided 2026-10-13). The generated span formats write nothing for it. + /// internal static string WrappedText(INamedType valueType, string value, string format, string provider) => ResolveStrategy(valueType) switch { ValueFormatStrategy.String => $"({value} ?? string.Empty)", + ValueFormatStrategy.Formattable or ValueFormatStrategy.SpanFormattable when valueType.IsReferenceType == true => + $"({value} is null ? string.Empty : global::CodoMetis.TypeKit.CompilerServices.GeneratedFormatting.ToString({value}, {format}, {provider}))", ValueFormatStrategy.Formattable or ValueFormatStrategy.SpanFormattable => $"global::CodoMetis.TypeKit.CompilerServices.GeneratedFormatting.ToString({value}, {format}, {provider})", // No formatting knowledge: the format and the provider have nothing to apply to. + _ when valueType.IsReferenceType == true => $"({value}?.ToString() ?? string.Empty)", _ => $"({value}.ToString() ?? string.Empty)" }; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/DefaultInstanceFormattingTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/DefaultInstanceFormattingTests.cs new file mode 100644 index 0000000..e432dac --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/DefaultInstanceFormattingTests.cs @@ -0,0 +1,101 @@ +using System.ComponentModel; +using System.Globalization; +using System.Net; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// A value object over a reference type that is span and UTF-8 formattable. +public readonly partial record struct DefaultedVersion : IValue; + +/// See . +public readonly partial record struct DefaultedAddress : IValue; + +/// +/// A default instance is not supposed to exist (CMTK0001), but EF Core, MVC and serializers make one, +/// and over a reference type it holds null. Its text is "" wherever it is formatted, as its +/// ToString() over a string always was: ToString(), interpolation, IFormattable, +/// both TryFormats, Convert.ToString, IConvertible and the type converter threw +/// over a Uri, a Version or an IPAddress, and +/// Convert.ToString and IConvertible over a string too. +/// +public sealed class DefaultInstanceFormattingTests +{ +#pragma warning disable CMTK0001 // The default instance is the subject. + public static TheoryData Wrapped => ["string", "Uri", "Version", "IPAddress"]; + + private static readonly Dictionary Defaults = new() + { + ["string"] = default(ProbeName), + ["Uri"] = default(ProbeUri), + ["Version"] = default(DefaultedVersion), + ["IPAddress"] = default(DefaultedAddress), + }; + + [Theory] + [MemberData(nameof(Wrapped))] + public void A_default_instance_over_a_reference_type_formats_as_an_empty_string(string wrapped) + { + var instance = Defaults[wrapped]; + + instance.ToString().ShouldBe(""); + $"{instance}".ShouldBe(""); + string.Format(CultureInfo.InvariantCulture, "{0}", instance).ShouldBe(""); + ((IFormattable)instance).ToString(null, null).ShouldBe(""); + Convert.ToString(instance, CultureInfo.InvariantCulture).ShouldBe(""); + TypeDescriptor.GetConverter(instance.GetType()).ConvertToInvariantString(instance).ShouldBe(""); + + if (instance is ISpanFormattable span) + { + span.TryFormat(new char[8], out var charsWritten, default, null).ShouldBeTrue(); + charsWritten.ShouldBe(0); + } + + if (instance is IUtf8SpanFormattable utf8) + { + utf8.TryFormat(new byte[8], out var bytesWritten, default, null).ShouldBeTrue(); + bytesWritten.ShouldBe(0); + } + } + + /// The cases above that a span or UTF-8 format would answer do run, so they cannot pass by being skipped. + [Fact] + public void The_span_formats_are_among_the_cases() + { + Defaults.Values.Count(instance => instance is ISpanFormattable).ShouldBe(3); + Defaults.Values.Count(instance => instance is IUtf8SpanFormattable).ShouldBe(2); + } + + /// + /// Over a string, IConvertible answers for the null a default instance holds as + /// answers for a null object: "" as text, the target type's default as + /// a number, as its type code. + /// + [Fact] + public void A_default_instance_over_a_string_converts_as_Convert_converts_a_null_object() + { + var convertible = (IConvertible)default(ProbeName); + + convertible.ToString(null).ShouldBe(""); + convertible.ToType(typeof(string), null).ShouldBe(""); + convertible.ToInt32(null).ShouldBe(Convert.ToInt32((object?)null, CultureInfo.InvariantCulture)); + convertible.ToBoolean(null).ShouldBe(Convert.ToBoolean((object?)null, CultureInfo.InvariantCulture)); + convertible.GetTypeCode().ShouldBe(Convert.GetTypeCode(null)); + } + + /// A value is converted as before: by its own IConvertible. + [Fact] + public void A_value_over_a_string_converts_as_the_string_does() + { + var convertible = (IConvertible)ProbeName.From("42"); + + convertible.ToInt32(null).ShouldBe(42); + convertible.ToString(null).ShouldBe("42"); + convertible.GetTypeCode().ShouldBe(TypeCode.String); + convertible.ToType(typeof(string), null).ShouldBe("42"); + convertible.ToType(typeof(long), null).ShouldBe(42L); + Should.Throw(() => ((IConvertible)ProbeName.From("x")).ToInt32(null)); + } +#pragma warning restore CMTK0001 +} From 0a00df7549ea071af20c778f77ac93f8337e0969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:24:42 +0200 Subject: [PATCH 119/147] docs: the discovery note says how closed sets are found, at compile time and at run time Co-Authored-By: Claude Opus 5.5 --- design/discovery.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/design/discovery.md b/design/discovery.md index d5afd72..226fbb9 100644 --- a/design/discovery.md +++ b/design/discovery.md @@ -10,6 +10,9 @@ passes in tests and breaks for the first consumer who renames something. | Analyzer, CMTK0002 | The compilation references an assembly whose name is exactly `CodoMetis.TypeKit.Generators`, ignoring case as assembly names do. That is the package's own id, which a consumer cannot rename, and a prefix or suffix match never counts. The assembly is not strong-named, so the name is the part of its identity to compare; the version must not be | | EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` ([EF Core](ef-core.md)). There is no type scan and no assembly filter | | OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata ([OpenAPI](openapi.md)). There is no referenced-assembly walk | +| Generators, closed sets | The fabric selects a class that implements `IClosedSet`, directly or through `IFaultSet` or another interface, comparing each implemented interface's definition with the symbol of `IClosedSet<>` ([Closed sets](closed-sets.md)). A member is a public static readonly field of the set's own type, found by its type, never by a name | +| Analyzer, closed-set rules | `IClosedSet`1` is resolved with `GetTypesByMetadataName` beside the value-object markers, and a set is a type whose interfaces' `OriginalDefinition` is one of those symbols (CMTK0002, CMTK0012) | +| Run time, closed sets | EF Core's type-mapping plugin, the OpenAPI schema transformer and the Serilog policy read `GeneratedClosedSetAttribute` on the type, whose type argument is constrained to `IClosedSet`; `Accept` hands the set to generic code as a type argument, so there is no `GetInterfaces()` and no `MakeGenericType` | Run-time code that holds only a `Type` reads the attribute rather than calling `GetInterfaces()` ([decision 17](adr/0017-run-time-discovery-reads-generatedvalueobjectattribute-tvalueobject.md)). Its type arguments are constrained to `IValueObject` and From 37da1c02bc064e2d9fd31c76eafd7df27f5f47d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:26:34 +0200 Subject: [PATCH 120/147] docs: the aspects note names a closed set as the wrapped type, not as the refused one Co-Authored-By: Claude Opus 5.5 --- design/aspects.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/aspects.md b/design/aspects.md index 9e56447..4bc3055 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -214,7 +214,7 @@ one's rules in its `Create`. Refused rather than made to work: a surface that follows the project layout is the silent failure this repo exists to prevent, and lifting a refusal later breaks nobody, while adding one after 1.0.0 would. - - **A closed set is not refused** (decided 2026-10-13, + - **A closed set as the wrapped type is not refused** (decided 2026-10-13, [decision 60](adr/0060-value-object-over-closed-set-generated-in-full.md)), wherever it is declared. Over a set of its own project `OverSet : IValue` lacked `Parse`, `TryParse`, `IParsable` and its own type converter while its JSON worked, because `ClosedSetAspect` ran last, after the From 88b074d78c07b374cf4b60082632e9b44bacf051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:30:26 +0200 Subject: [PATCH 121/147] docs: the changelog and the migration guide carry the generators' refusals, TryParse, obsolete and default-instance changes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 +++++++++++++++++++++++++-- docs/migrating-to-2.md | 14 +++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e0465..235fc9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ A major version: it targets `net10.0` and Metalama 2026.1 as 1.x does, and the b Semantic Versioning reserves for a major, listed under Changed. Package validation against 1.5.1 finds one difference in the public surface, `FaultProblem`'s constraint, which still accepts every type argument it accepted; the others are behaviour no compatibility check reads: CMTK0003 at -Error, `FirstOrNone` on a query, CMTK1022, and the EF Core and OpenAPI refusals below. The new +Error, `FirstOrNone` on a query, CMTK1022 and the new CMTK1005 refusals, `TryParse` letting an +exception from `Create` through, and the EF Core and OpenAPI refusals below. The new analyzer Errors, CMTK0010 to CMTK0012, report only on what this release adds, so nothing that built against 1.5.1 meets them. @@ -59,7 +60,9 @@ against 1.5.1 meets them. CMTK1021 refuses a member named as a generated member, or two whose names differ only in case (`Open` and `open`, `Express` and `EXPRESS`), which collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference. `Match` checks every delegate for null before it picks one, - and a set is a JSON dictionary key by its name. + and a set is a JSON dictionary key by its name. A value object can wrap a closed set, from its own + project too, and is generated in full: `ClosedSetAspect` runs first, so the value object's + `Parse`, `TryParse`, `IParsable` and type converter see what the set's aspect adds. `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name, in a primitive collection and a JSON column too, through a compiled model and under Native AOT (`ClosedSetConverter` names it explicitly); `CodoMetis.TypeKit.AspNetCore` describes it as a @@ -239,6 +242,26 @@ against 1.5.1 meets them. an `ArgumentNullException` naming it, where the await threw a `NullReferenceException`. - **`CodoMetis.TypeKit.Testing`**: a failure names a nested type after its containing types, `Option`; a type nested in a generic one threw `ArgumentOutOfRangeException` instead. +- **`CodoMetis.TypeKit.Generators`**: CMTK1005 refuses a value object over `object`, which was + written to JSON as `{}`, and over `System.Enum`, and one named as a member the generators add to a + value object (`From`, `TryFrom`, `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, + `MinValue`, `MaxValue`, `CompareTo`). CMTK1021 refuses a closed set named as one of its generated + members, and CMTK1011 a hand-written `operator ==` or `!=` between two of the type. Apart from + `object`, a value object named `Revalidate` that is not validated and one named `CompareTo` over a + type with no order, each failed inside the generated code before, as LAMA0611 or a bare CS0111, + naming nothing the user wrote. +- **`CodoMetis.TypeKit.Generators`**: `TryParse` over a wrapped type parsed by a type converter, a + static `Parse` or a string constructor lets an exception from `Create` through, as `Parse` does and + as `TryParse` over a wrapped type with a `TryParse` of its own did; it answered `false`. + +### Fixed + +- **`CodoMetis.TypeKit.Generators`**: a value object marked `[Obsolete(..., error: true)]`, or nested in + such a type, builds; its companion class failed with CS0619 inside the generated code. +- **`CodoMetis.TypeKit.Generators`**: a default instance of a value object over a reference type + formats as an empty string everywhere, `ToString`, `IFormattable`, `TryFormat`, the type converter + and `Convert.ToString`, as one over a `string` already did for `ToString`, where it threw a + `NullReferenceException`; its `IConvertible` answers as `Convert` answers for null. ## 1.5.1 — 2026-10-11 diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md index 8cd0ea6..3efbe94 100644 --- a/docs/migrating-to-2.md +++ b/docs/migrating-to-2.md @@ -57,7 +57,7 @@ CMTK0010, CMTK0011 and CMTK0012 are new errors too, but each reports only on wha type-parameter attributes, `[RequireParameterlessConstructor]` and a closed set's `Match`. No code that built against 1.5 meets them. -## An abstract value object is a build error +## Value objects that are build errors now In 1.5, an abstract type that declared a value-object marker built: it generated nothing of its own, its sealed derived types were generated, and a value held as the abstract type was written to JSON as @@ -72,6 +72,18 @@ public abstract record Money; public sealed partial record Euro : Money, IValue; // the marker on the sealed type, which is generated ``` +CMTK1005 refuses two more declarations 1.5 built. A value object over `object` was written to JSON as +`{}`: wrap the type the value has. A value object named as a member the generators add to it, which +1.5 mostly failed on inside the generated code, built where the member was not generated: one named +`Revalidate` that is not validated, or `CompareTo` over a type with no order. Rename it. + +## TryParse lets an exception from Create through + +Over a wrapped type parsed by a type converter, a static `Parse` or a string constructor, a +generated `TryParse` turned an exception thrown by your `Create` into `false`. In 2.0 it propagates, +as it does from `Parse` and from `TryParse` over a wrapped type with a `TryParse` of its own. Return a +fault from `Create` for input you mean to refuse. + ## Queries and documents that ran wrongly now throw Three things that ran in 1.5 and answered wrongly throw in 2.0: From 7ce6bc4f88b9b465befdafda5ec6d50d07e1471d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:30:43 +0200 Subject: [PATCH 122/147] docs: the package READMEs say a refused dictionary key is in the JsonException's path Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Generators/README.md | 4 +++- src/CodoMetis.TypeKit/README.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 9f55ee1..dadd5bd 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -341,7 +341,9 @@ provider-less `Parse` ignore it. Text the wrapped type cannot read is a `FormatException` naming the value object and the wrapped type ("Quantity could not read the input as Int32."), an overflow included, with no inner exception: -the wrapped type's own message quotes the input. A refusal by `Create` names the fault instead. +the wrapped type's own message quotes the input. A refusal by `Create` names the fault instead. The +one place the input still appears is System.Text.Json's own: a refused dictionary key ends the +`JsonException`'s `Path`. `TryParse` answers `false` for both, and lets anything else through as `Parse` does: an exception `Create` throws is a bug to see, not text to refuse. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index f37bdc0..b4d6f69 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -600,7 +600,9 @@ converter, or `JsonException: Quantity could not read the JSON value as Int32.` **Why:** the wrapped type's own message quotes the input ("The input string '…' was not in a correct format."), so it is replaced, and not kept as the inner exception either; see the [design](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/design/adr/README.md), -decision 27. A JSON error still carries its path. +decision 27. A JSON error still carries its path, and for a value object read as a dictionary key +the path ends in the key itself (`$.items.SE`), which MVC uses as the error's name in its 400: keep a +secret out of a dictionary's keys. **Instead:** `TryParse` for text that may not parse. Text that is yours to log, log before parsing. From 84e97764c32e8896172d027a08924c70d674e671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:30:44 +0200 Subject: [PATCH 123/147] fix: a value-object key over char is the application's to assign, and a default ulong key's throw at Add is documented A key over char is no longer declared generated, on any provider, as 1.5.1 left it. No provider generates a char key, and on SQL Server, whose column is an nvarchar(1), the value object's key declared generated was given a character of a GUID string at Add, which was then stored: EF picks the generator that fills a value object's key by the column's type, not by the wrapped type. The application's own ValueGeneratedOnAdd() or value generator still applies. A default '\0' is stored as it is. A key over ulong stays declared generated, and a test pins what the docs now say: a default key throws OverflowException at Add on SQL Server and PostgreSQL, whose column is a decimal, and ValueGeneratedNever() takes an assigned key. Decision 63 refines decision 58 with the measurements per provider; the README, the docs page, the design note and the convention's XML docs state the rule with these two exceptions in place of parity. Co-Authored-By: Claude Opus 5.5 --- ...s-application-assigned-ulong-documented.md | 44 ++++++++ design/adr/README.md | 1 + design/ef-core.md | 46 ++++---- docs/ef-core.md | 12 +- .../README.md | 30 +++-- ...ValueObjectKeyValueGenerationConvention.cs | 46 ++++++-- .../KeyGenerationTests.cs | 106 +++++++++++++++++- 7 files changed, 237 insertions(+), 48 deletions(-) create mode 100644 design/adr/0063-char-keys-application-assigned-ulong-documented.md diff --git a/design/adr/0063-char-keys-application-assigned-ulong-documented.md b/design/adr/0063-char-keys-application-assigned-ulong-documented.md new file mode 100644 index 0000000..3bf9c0d --- /dev/null +++ b/design/adr/0063-char-keys-application-assigned-ulong-documented.md @@ -0,0 +1,44 @@ +# 0063. A key over `char` is the application's to assign; a default key over `ulong` throws at `Add` + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [EF Core](../ef-core.md); refines +[decision 58](0058-integer-key-follows-ef-rule.md). + +Decision 58 declared a value-object key generated wherever EF declares a key of its wrapped type +generated, and called that parity. It is narrower than parity. EF separates declaring a key generated +(`ValueGenerated`) from supplying what generates it, which the provider decides (an identity, a +sequence), and it picks the generator that fills a key at `Add` by the property's CLR type, else by its +converter's provider type, which for a value object is the column's. Where the provider stores the +wrapped type through a converter of its own, a value object's default key gets another generator than +a plain key. Measured with EF Core 10.0.12, Npgsql 10.0.3 and SQL Server's provider 10.0.12: the models +and `Add` on all three providers, the tables and inserts on PostgreSQL 18.1 (no SQL Server here). + +| Key over | PostgreSQL | SQL Server | SQLite | +|---|---|---|---| +| `char` | `character(1)`, identity; refused with 22023 at creation and in the `AlterColumn` from a 1.5.1 snapshot, for a plain key too | `nvarchar(1)`, no identity; the value object's default key got a permanent character of a GUID string at `Add` (`9`, `b`), a plain one a temporary value | `TEXT`; a plain key's `AUTOINCREMENT` refused at creation, the value object's key a temporary value | +| `ulong` | `numeric(20,0)`, no identity; the value object's default key throws `OverflowException` at `Add`, a plain one gets a temporary `ulong` and fails at insert (23502) | `decimal(20,0)`, identity; the value object's default key throws `OverflowException` at `Add`, a plain one gets a temporary `ulong` | `INTEGER`; numbered 1, 2 | + +**A key over `char` is never declared generated**, on any provider, as 1.5.1 left it. No provider +generates one, and declared generated it was the one case where the value object did worse than a +plain key without a word: on SQL Server the invented character was stored. This stops the invented +value and checks nothing more: a default `'\0'` is stored as it is, and a second such row fails on the +primary key. The application assigns the key; an explicit `ValueGeneratedOnAdd()` or a value generator +of its own still applies (convention precedence). + +**A key over `ulong` stays declared generated**, as a plain one is, which keeps SQL Server's identity +column. It fails loudly, at `Add`, where the column is a `decimal`: EF's temporary `decimal` is negative, +and converting it back to `ulong` throws. Making `Add` give it a temporary `ulong` needs a value +generator per key. A delegate given to `HasValueGenerator` is refused by `dbcontext optimize` ("has a +value generator configured. Use 'HasValueGeneratorFactory' to configure the value generator factory +type"), a factory type would have to be public for the compiled model's code to name it, and either +takes precedence over the providers' HiLo, which they select only for a property without one. A type +mapping's own factory, which comes after HiLo, has no public setter on the provider's mapping a value +object's is composed onto. The workaround is `ValueGeneratedNever()` and a key the application assigns. + +`KeyGenerationTests` holds both: a `char` key is `Never` on the three providers and `Add` leaves a +default key as it is (with the key declared generated again, SQL Server's test failed with `b`, the +others' with a temporary value), the application's own configuration still applies, and a default +`ulong` key throws at `Add` on SQL Server and PostgreSQL while `ValueGeneratedNever()` takes an +assigned one. diff --git a/design/adr/README.md b/design/adr/README.md index 94e1fd6..0dd4940 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -69,3 +69,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0060](0060-value-object-over-closed-set-generated-in-full.md) | A value object over a closed set is generated in full wherever the set is declared | 2026-10-13 | accepted | | [0061](0061-scope-s-fault-status-applies-where-fault-type-answered.md) | A scope's fault status applies where its fault type is answered | 2026-10-13 | accepted | | [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | +| [0063](0063-char-keys-application-assigned-ulong-documented.md) | A key over `char` is the application's to assign; a default key over `ulong` throws at `Add` | 2026-10-13 | accepted | diff --git a/design/ef-core.md b/design/ef-core.md index ebbac86..815e768 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -68,8 +68,8 @@ generation convention reads the property's CLR type, so a value-object key was never generated: an entity added without one was stored as 0, and switching an `int` key to a value object dropped its identity column. A convention plugin marks a single-column, non-foreign key over the integer types - EF generates (`int`, `long`, `short`, `sbyte`, the unsigned ones and `char`, as EF's `IsInteger()` - minus `byte`) as generated on add, at convention precedence. It runs after the last + EF generates (`int`, `long`, `short`, `sbyte` and the unsigned ones, as EF's `IsInteger()` minus + `byte` and `char`) as generated on add, at convention precedence. It runs after the last hierarchy-mapping and shared-table convention: SQLite re-decides a key from its CLR type when the root's mapping strategy is set, which reset a TPH root's key to not generated, and a TPC key stays generated only where the provider gave it a sequence, as for a plain `int` @@ -79,23 +79,31 @@ every entity was stored with 0). The model tests build each shape for SQLite, Npgsql and SQL Server, an abstract TPC root included. Where the provider has no identity for the type, a plain key is not generated either, and neither is the value object's (measured on PostgreSQL 18: a `ulong` is - `numeric(20,0)`, strategy none; a `char` is `character(1)`, whose identity PostgreSQL refuses with - 22023 at creation and in the `AlterColumn` a 1.5.1 snapshot produces). That `AlterColumn` adds the - identity to a key over `sbyte`, `ushort` or `uint` on PostgreSQL; SQL Server's migrations generator - refuses it for those and `ulong` ("To change the IDENTITY property of a column, the column needs to - be dropped and recreated"), as for any key whose identity changes. A key over a `Guid` is left - alone: the application assigns `OrderId.New()`, and a generated key that is already set makes EF - take a new entity reached through a navigation for an existing one. - - **Open: the value generator is chosen by the column's type (measured 2026-10-13).** EF picks a - key's generator by its CLR type, else by its converter's provider type, which for a value object - is the column's: where the provider stores the wrapped type through a converter of its own, a - default key gets another generator than a plain key. A default `ulong` key throws - `OverflowException` on `Add` on SQL Server and PostgreSQL (EF's temporary `decimal` is negative), - where a plain one gets a temporary value; a default `char` key on SQL Server gets a permanent - character from a GUID string, where a plain one fails at insert. An assigned key is unaffected. - (The models and `Add` measured on all three providers, the inserts on PostgreSQL 18; SQL Server's - inserts follow from its models, unrun.) A fix needs a value generator per key that a compiled - model can write and that leaves HiLo alone. + `numeric(20,0)`, strategy none). From a 1.5.1 snapshot, the `AlterColumn` that declares a key + generated adds the identity to a key over `sbyte`, `ushort` or `uint` on PostgreSQL; SQL Server's + migrations generator refuses it for those and `ulong` ("To change the IDENTITY property of a column, + the column needs to be dropped and recreated"), as for any key whose identity changes. A key over a + `Guid` is left alone: the application assigns `OrderId.New()`, and a generated key that is already + set makes EF take a new entity reached through a navigation for an existing one. + - **Declared generated is not parity (decided 2026-10-13, [decision 63](adr/0063-char-keys-application-assigned-ulong-documented.md)).** + The convention declares a key generated (`ValueGenerated`); the provider supplies the mechanism, + and EF picks the generator that fills a key at `Add` by its CLR type, else by its converter's + provider type, which for a value object is the column's. Where the provider stores the wrapped + type through a converter of its own, a value object's default key therefore gets another + generator than a plain key. Two consequences, measured on all three providers' models and `Add`, + and on PostgreSQL 18's inserts: + - **A key over `char` is never declared generated.** No provider generates one: PostgreSQL refuses + an identity on `character(1)` (22023, at creation and in the `AlterColumn` from a 1.5.1 + snapshot), SQLite refuses the `TEXT … AUTOINCREMENT` column of a plain key, and a plain key on + SQL Server (`nvarchar(1)`, no identity) reaches the insert with only a temporary value, which EF + leaves out (read from the model; no SQL Server here). On SQL Server the value object's key, declared generated, got a permanent + character of a GUID string at `Add`, which was stored. It is `Never`, as in 1.5.1; an explicit + `ValueGeneratedOnAdd()` or value generator still applies. A default `'\0'` is stored as it is. + - **A default key over `ulong` throws `OverflowException` at `Add`** on SQL Server and PostgreSQL, + whose column is a `decimal`: EF's temporary `decimal` is negative. A plain `ulong` key gets a + temporary `ulong`. It stays declared generated, since it fails loudly and a fix needs a value + generator per key that a compiled model can write and that leaves HiLo alone; the workaround is + `ValueGeneratedNever()` and an assigned key, which a test pins with the exception. - **Conventions for the wrapped type do not reach a value object.** `ConfigureConventions` (`Properties()`) matches the property's CLR type; the README says to configure the value object (`Properties()`). Measured 2026-09-28 for precision, maximum length, column type and diff --git a/docs/ef-core.md b/docs/ef-core.md index d3a6b4a..5a16b78 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -91,11 +91,13 @@ value object's type, since EF applies `ConfigureConventions` by CLR type. ## Keys -A single-column key over an integer is generated on add, as a key of that integer type is: an -identity column on PostgreSQL and SQL Server, so switching an `int` key to a value object there -changes no schema. Where the provider has no identity for the type, a plain key is not generated -either: on PostgreSQL a `ulong` key is a `numeric(20,0)` and a `char` key a `character(1)`, neither of -which can be an identity, so configure `ValueGeneratedNever()` on such a key and assign it. A key over +A single-column key over an integer is declared generated on add, as a key of that integer type is, +and the provider generates it as it would a plain one: an identity column on PostgreSQL and SQL +Server, so switching an `int` key to a value object there changes no schema. Two exceptions. A key +over `char` is the application's to assign on every provider, since none generates one; a default +`'\0'` is stored as it is, so assign a meaningful character. A key over `ulong` added with its default +value throws `OverflowException` at `Add` on SQL Server and PostgreSQL, whose column is a `decimal`; +configure `ValueGeneratedNever()` on it and assign it. A key over a `Guid` is the application's to assign, `Id = OrderId.New()`, a version 7 `Guid` that sorts by creation time; for EF to generate it, configure `ValueGeneratedOnAdd()`. The root of a TPH or TPT hierarchy is generated too; under TPC, as for an `int` key, only where the provider has diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 612f5fc..0c96579 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -67,14 +67,28 @@ would be a `default` instance.) A property can still name the converter explicit modelBuilder.Entity().Property(o => o.Code).HasConversion>(); ``` -**Keys.** A single-column key over an integer is generated on add, as a key of that integer type is. -That is EF's own list: `int`, `long`, `short`, `sbyte`, `uint`, `ulong`, `ushort` and `char`, and not -`byte`. On PostgreSQL and SQL Server it is an identity column, so switching an `int` -key to a value object there changes no schema. Where the provider has no identity for the type, the key -is no more generated than a plain one: on PostgreSQL a `ulong` is a `numeric(20,0)`, which cannot be an -identity, and a `char` a `character(1)`, on which PostgreSQL refuses one when the table is created or -migrated (22023), as it does for a plain `char` key. Configure `ValueGeneratedNever()` on such a key -and assign it. A key over a `Guid` is the application's to assign, +**Keys.** A single-column key over an integer is declared generated on add, as a key of that integer +type is: `int`, `long`, `short`, `sbyte`, `uint`, `ulong` and `ushort`, EF's own list, and not `byte`. +Whether a value is then generated is the provider's to decide, as for a plain key. On PostgreSQL and +SQL Server it is an identity column, so switching an `int` key to a value object there changes no +schema; where the provider has none for the type, the key is no more generated than a plain one (on +PostgreSQL a `ulong` is a `numeric(20,0)`, which cannot be an identity). Two exceptions, because EF +picks the generator that fills a value object's key at `Add` by the column's type, not by the type it +wraps: + +- **A key over `char` is the application's to assign.** It is never declared generated, on any + provider: none generates a `char` key (PostgreSQL refuses an identity on `character(1)`, SQLite an + `AUTOINCREMENT` on `TEXT`, and SQL Server has no identity for `nvarchar(1)`), and on SQL Server + `Add` gave the value object a character of a GUID string, which was stored. This stops the invented + value; it does not check that the application set a meaningful one. A default key, `'\0'`, is + stored as it is, and a second such row fails on the primary key. `ValueGeneratedOnAdd()` or a value + generator of your own on the property still applies. +- **A default key over `ulong` throws at `Add` where the column is a `decimal`** (SQL Server, + PostgreSQL): EF's temporary value for the column is a negative `decimal`, and converting it back + throws `OverflowException`, where a plain `ulong` key gets a temporary `ulong`. Configure + `ValueGeneratedNever()` on such a key and assign it. + +A key over a `Guid` is the application's to assign, `Id = OrderId.New()` (a version 7 Guid, which sorts by creation time), and EF inserts it as given. For EF to generate it instead, configure `ValueGeneratedOnAdd()` on the property; EF then also takes an entity whose key is already set, reached through a navigation, for an existing one. The root of an diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs index 8cf4156..934f884 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs @@ -7,22 +7,44 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore; /// -/// A single-column primary key that is a value object over an integer is generated on add, as a key -/// of that integer type is: EF's own convention looks at the property's CLR type and leaves every +/// A single-column primary key that is a value object over an integer is declared generated on add, as +/// a key of that integer type is: EF's own convention looks at the property's CLR type and leaves every /// value object alone, so OrderNo : IValue<int> lost the identity column int had, /// and an entity added without a key was stored as 0. /// /// /// -/// The integer types are those EF generates a key of (), so a -/// value-object key follows what EF does for the type it wraps. A value object over a -/// is left as EF leaves it, never generated: an identifier the application -/// creates itself (OrderId.New()) is then always inserted, whereas a generated key that is set -/// makes EF take a new entity reached through a navigation for an existing one. +/// The integer types are those EF declares a key of generated (), +/// with one exception, , which is the application's to assign. This declares the key +/// generated; whether a value is then generated is the provider's to decide, as for a plain key (an +/// identity, a sequence), and EF picks the generator that fills a value object's key at Add by +/// the column's type, not by the wrapped type. So the rule is narrower than parity with the wrapped type +/// (decisions 58 and 63): /// +/// +/// +/// A key over is never generated. No provider generates one (PostgreSQL refuses an +/// identity on character(1), SQLite an AUTOINCREMENT on TEXT, and SQL Server has no +/// identity for nvarchar(1)), and on SQL Server a value object's key declared generated was given +/// a character of a GUID string at Add, which was then stored. A default '\0' is stored as +/// it is: nothing checks that the application set a meaningful character. +/// +/// +/// A key over stays declared generated, as a plain one is, and fails loudly where the +/// column is a decimal (SQL Server, PostgreSQL): a default key throws +/// at Add, from EF's negative temporary decimal. ValueGeneratedNever() and an +/// assigned key are the workaround. +/// +/// /// -/// It sets the convention's value only, so ValueGeneratedNever() or any other explicit -/// configuration still wins, and a key that is also a foreign key is left alone. +/// A value object over a is left as EF leaves it, never generated: an identifier the +/// application creates itself (OrderId.New()) is then always inserted, whereas a generated key +/// that is set makes EF take a new entity reached through a navigation for an existing one. +/// +/// +/// It sets the convention's value only, so ValueGeneratedNever(), ValueGeneratedOnAdd(), a +/// value generator or any other explicit configuration still wins, and a key that is also a foreign key +/// is left alone. /// /// internal sealed class ValueObjectKeyValueGenerationConvention : IModelFinalizingConvention @@ -50,12 +72,12 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven /// /// EF's own rule for a key of the wrapped type (ValueGenerationConvention.CanBeGenerated in - /// EF Core 10): every integer type but , the unsigned ones, and - /// included. + /// EF Core 10), every integer type but , the unsigned ones and + /// included, without , which EF counts as one and no provider generates. /// private static bool IsGeneratedByTheDatabase(Type wrapped) => wrapped == typeof(int) || wrapped == typeof(long) || wrapped == typeof(short) || wrapped == typeof(sbyte) - || wrapped == typeof(uint) || wrapped == typeof(ulong) || wrapped == typeof(ushort) || wrapped == typeof(char); + || wrapped == typeof(uint) || wrapped == typeof(ulong) || wrapped == typeof(ushort); } /// diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs index 69c2e4b..915f018 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/KeyGenerationTests.cs @@ -123,9 +123,10 @@ public static TheoryData IntegerKeys } /// - /// EF generates a key of every integer type but byte, unsigned ones and char included + /// EF generates a key of every integer type but byte, unsigned ones included /// (ValueGenerationConvention), and a key over one follows it. Those over uint, - /// ulong, ushort, sbyte and char were never generated. + /// ulong, ushort and sbyte were never generated. char, which EF counts + /// as an integer too, is the application's to assign (). /// [Theory] [MemberData(nameof(IntegerKeys))] @@ -136,6 +137,71 @@ public void A_key_over_an_integer_type_is_generated_as_a_key_of_that_type_is(str Key(db, keyedByValueObject).ValueGenerated.ShouldBe(Key(db, keyedByWrapped).ValueGenerated, $"{keyedByValueObject.Name} on {provider}"); } + public static TheoryData AllProviders => [.. Providers]; + + /// + /// No provider generates a char key: PostgreSQL refuses an identity on character(1), and + /// a plain one on SQL Server or SQLite reaches the insert without a value. Marked generated, the value + /// object's key on SQL Server got a character of a GUID string at Add, which was then stored + /// (EF picks the generator by the column's type, nvarchar). So it is not generated: the key the + /// application set is the key added, a default one included, as in 1.5.1. + /// + [Theory] + [MemberData(nameof(AllProviders))] + public void A_key_over_char_is_the_application_s_to_assign(string provider) + { + using var db = new CharAndULongKeysDb(Options(provider)); + + var unassigned = new KeyedByChar(); + var assigned = new KeyedByChar { Id = CharKey.From('B') }; + db.AddRange(unassigned, assigned); + + // The entry's value, which EF inserts; a temporary one is kept there, not on the entity. + var added = db.Entry(unassigned).Property(e => e.Id); + added.CurrentValue.Value.ShouldBe('\0', $"on {provider}, Add invented a key"); + added.IsTemporary.ShouldBeFalse($"on {provider}"); + unassigned.Id.Value.ShouldBe('\0', $"on {provider}, Add invented a key"); + assigned.Id.Value.ShouldBe('B'); + Key(db, typeof(KeyedByChar)).ValueGenerated.ShouldBe(ValueGenerated.Never, $"on {provider}"); + } + + /// The application's own configuration of a char key still wins: generated on add, or its own generator. + [Theory] + [MemberData(nameof(AllProviders))] + public void A_char_key_the_application_configures_keeps_its_configuration(string provider) + { + using var db = new CharAndULongKeysDb(Options(provider)); + + Key(db, typeof(GeneratedOnAddCharKey)).ValueGenerated.ShouldBe(ValueGenerated.OnAdd, $"on {provider}"); + + var own = new OwnGeneratorCharKey(); + db.Add(own); + own.Id.Value.ShouldBe('A', $"on {provider}"); + } + + /// + /// Pinned as documented, not as wanted: where the provider stores a ulong as a decimal + /// (SQL Server, PostgreSQL), EF gives a value object's default key a temporary decimal, which is + /// negative, and converting it back throws at Add; a plain ulong key gets a temporary + /// ulong. Configured ValueGeneratedNever(), the key the application assigns is added. + /// + [Theory] + [InlineData("sqlserver")] + [InlineData("npgsql")] + public void A_default_key_over_ulong_throws_at_Add_where_the_column_is_a_decimal(string provider) + { + using var db = new CharAndULongKeysDb(Options(provider)); + + Should.Throw(() => db.Add(new KeyedByUInt64())); + + var assigned = new AssignedUInt64Key { Id = UInt64Key.From(42) }; + db.Add(assigned); + + assigned.Id.Value.ShouldBe(42ul); + db.Entry(assigned).Property(e => e.Id).IsTemporary.ShouldBeFalse(); + Key(db, typeof(AssignedUInt64Key)).ValueGenerated.ShouldBe(ValueGenerated.Never); + } + /// /// Each integer type SQLite fills a key of, added twice. Not char: EF's own char key asks /// SQLite for a TEXT column with AUTOINCREMENT, which it refuses. @@ -408,7 +474,10 @@ public class KeyedByChar { public CharKey Id { get; set; } } public class KeyedByPlainChar { public char Id { get; set; } } -/// A key over each integer type, beside a key of that type itself. For the model only: SQLite refuses EF's own char key. +/// +/// A key over each integer type, beside a key of that type itself, for the model only. Not char, +/// which no provider generates and a value object's key leaves to the application (). +/// public sealed class IntegerKeysDb(DbContextOptions options) : DbContext(options) { public static readonly (Type ByValueObject, Type ByWrapped)[] Pairs = @@ -420,7 +489,6 @@ public static readonly (Type ByValueObject, Type ByWrapped)[] Pairs = (typeof(KeyedByUInt32), typeof(KeyedByUint)), (typeof(KeyedByUInt64), typeof(KeyedByUlong)), (typeof(KeyedByByte), typeof(KeyedByPlainByte)), - (typeof(KeyedByChar), typeof(KeyedByPlainChar)), ]; protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -433,6 +501,36 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } +/// A char key configured to be generated on add. +public class GeneratedOnAddCharKey { public CharKey Id { get; set; } } + +/// A char key given a value generator of the application's own. +public class OwnGeneratorCharKey { public CharKey Id { get; set; } } + +/// A ulong key the application assigns, as the README says to configure it. +public class AssignedUInt64Key { public UInt64Key Id { get; set; } } + +/// Hands out the letter A, as an application's own generator would hand out its keys. +public sealed class LetterKeyGenerator : Microsoft.EntityFrameworkCore.ValueGeneration.ValueGenerator +{ + public override bool GeneratesTemporaryValues => false; + + public override CharKey Next(Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry) => CharKey.From('A'); +} + +/// Keys over char and ulong, by convention and as an application configures them. +public sealed class CharAndULongKeysDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.Id).HasValueGenerator(); + modelBuilder.Entity(); + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedNever(); + } +} + /// The keys over the integer types SQLite fills in. public sealed class SqliteIntegerKeysDb(DbContextOptions options) : DbContext(options) { From 04dd9c29eaf4f5adfe9551c0644525cddf68adb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:31:46 +0200 Subject: [PATCH 124/147] docs: the changelog and the migration guide say which small-integer keys a provider generates, that char keys are assigned, and what a default ulong key does Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++++++++---- docs/migrating-to-2.md | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 235fc9e..cfb57b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -226,10 +226,15 @@ against 1.5.1 meets them. by `.Value` where the column's order is meant. The refusal covers an ordering over a navigation and a key cast to `object`, and a query filter that does so is refused when the model is built, naming the entity type and the filter. -- **`CodoMetis.TypeKit.EntityFrameworkCore`**: a value-object key is generated on add wherever EF - generates its wrapped type's: over `sbyte`, `uint`, `ulong`, `ushort` and `char` too, and at the - root of a TPH hierarchy on SQLite, which stored 0. A TPC key on SQLite is not generated, as a plain - `int` key is not. A migration of such a model adds the identity. +- **`CodoMetis.TypeKit.EntityFrameworkCore`**: a value-object key is declared generated on add wherever + EF declares a key of its wrapped type generated: over `sbyte`, `uint`, `ulong` and `ushort` too, and + at the root of a TPH hierarchy on SQLite, which stored 0. A TPC key on SQLite is not generated, as a + plain `int` key is not. The provider decides whether a value is generated, as for a plain key: on PostgreSQL a + migration adds an identity to a key over `sbyte`, `ushort` or `uint`, and SQL Server's migrations + generator refuses that change ("the column needs to be dropped and recreated"). A key over `char` + stays the application's to assign, on every provider, since none generates one. A key over `ulong` + added with its default value throws `OverflowException` at `Add` on SQL Server and PostgreSQL, whose + column is a `decimal`; configure `ValueGeneratedNever()` on it and assign it. - **`CodoMetis.TypeKit.AspNetCore`**: the OpenAPI document describes `fault`, an optional string, on the problem schema the fault responses refer to, in 3.1 and 3.0 and inline where the host inlines problem details, with a description of the form the host writes. 1.5's `ProblemDetails` component diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md index 3efbe94..14ec425 100644 --- a/docs/migrating-to-2.md +++ b/docs/migrating-to-2.md @@ -94,7 +94,8 @@ Three things that ran in 1.5 and answered wrongly throw in 2.0: property with TypeKit's converter, or compare the value object itself. - **Ordering by a hand-written `CompareTo`.** A query that compares, orders, or takes `Min` or `Max` of a value object that declares its own `CompareTo` was ordered by the column, the opposite of the - in-memory order for a reversed one; it is refused now, in a query filter too. Order by `.Value` + in-memory order for a reversed one; it is refused now, and a query filter that does so is refused + when the model is built. Order by `.Value` where the column's order is meant ([EF Core](ef-core.md#queries)). - **A component name two types claim.** A document in which a nested value object's or closed set's component name, `Order.Status` as `OrderStatus`, is also another type's described both as one; it @@ -120,6 +121,19 @@ A test that asserts an exception or a message meets these: alone. - CMTK0009's message names `SingleOrNone` and `ElementAtOrNone` beside the other replacements. +## Keys over small integer types + +A value-object key over `sbyte`, `ushort`, `uint` or `ulong` is now declared generated on add, as EF +declares a plain key of that type, so a migration created against your 1.5 snapshot alters the key +column. On PostgreSQL it adds an identity to a key over `sbyte`, `ushort` or `uint`, which applies +cleanly; on SQL Server the migrations generator refuses the change for all four ("To change the +IDENTITY property of a column, the column needs to be dropped and recreated"). A key over `ulong` is +not generated on PostgreSQL, since `numeric(20,0)` has no identity, and adding an entity with a +default `ulong` key throws `OverflowException` at `Add` on PostgreSQL and SQL Server, where 1.5 +stored 0. To keep 1.5's behaviour for any of these keys, configure `ValueGeneratedNever()` on it and +assign the key. A key over `char` is unchanged: the application's to assign, a default `'\0'` stored +as it is ([EF Core](ef-core.md#keys)). + ## A query's FirstOrNone runs in the database In 1.5, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` on an EF Core query bound to From 94beab0bc3edeee3714218a94d5a586889fd7284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:45:34 +0200 Subject: [PATCH 125/147] fix: CMTK1021 refuses two members whose Match parameters share a name, as a Kelvin sign and a K do CMTK1021 grouped a closed set's member names ignoring case ordinally, which stood in for both collisions it exists for and missed one: the Kelvin sign (U+212A) lower-cases to k, so a member starting with it and one starting with K gave Match one parameter name twice (LAMA0611, CS0100), while ordinal ignore-case, which upper-cases, tells them apart. CMTK1021 now compares by each transform itself, TryParse(ignoreCase: true)'s and the one that names Match's parameters, and says which of them collides; Express and EXPRESS are told they collide in TryParse alone. Co-Authored-By: Claude Opus 5.5 --- design/closed-sets.md | 4 ++- docs/closed-sets.md | 2 +- .../ClosedSetAspect.cs | 13 ++++---- .../ClosedSetTypes.cs | 31 +++++++++++++++++++ src/CodoMetis.TypeKit.Generators/README.md | 2 +- .../BuildOutcomeTests.cs | 22 ++++++++++++- 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/design/closed-sets.md b/design/closed-sets.md index 62d0a2a..f9b6c33 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -28,7 +28,9 @@ The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). the EF column, binding and `IParsable` read the name as stored or sent; `ignoreCase: true` is an overload the caller opts into. Two members whose names differ only in case are refused (CMTK1021), since they are one name to that overload and, where the first letter differs, one `Match` - parameter. + parameter. CMTK1021 compares by each transform itself, ordinal ignoring case for the overload and + the parameters' own lower-casing of the first letter for `Match`: they differ for the Kelvin sign + (U+212A), which only the second takes for `K` (decided 2026-10-13, after a LAMA0611). - **No refusal quotes the input** ([decision 27](adr/0027-no-refusal-quotes-input.md)): an unknown name is a `FormatException` or a `JsonException` naming the set, and its message links the README's entry ([decision 37](adr/0037-refusal-s-message-landing-page.md)). diff --git a/docs/closed-sets.md b/docs/closed-sets.md index 186e4be..2a50c49 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -175,7 +175,7 @@ A declaration they cannot complete fails the build, naming it; the generators' | is generic, or nested in a generic type | CMTK1018 | | has a member not created with `new` where it is declared: `Legacy = Open` would give one instance two names | CMTK1019 | | is a record: `with` would copy a member into an instance that is none | CMTK1020 | -| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), which collide in `Match`'s parameters and in `TryParse(name, ignoreCase: true)`, or is itself named as a generated member (`Name`, `Match`): a member cannot have the name of its type | CMTK1021 | +| has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), which collide in `Match`'s parameters and in `TryParse(name, ignoreCase: true)`, or two whose `Match` parameters would have one name (a Kelvin sign and a `K`), or is itself named as a generated member (`Name`, `Match`): a member cannot have the name of its type | CMTK1021 | | writes a generated member by hand, `ToString()` apart | CMTK1011 | | is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index 2dfe267..bd675fb 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -64,8 +64,10 @@ public override void BuildAspect(IAspectBuilder builder) // A member named as a generated member failed the aspect (LAMA0506 beside Match, Parse and // TryParse, LAMA0611 beside ToString). Two members whose names differ only in case are one - // name to TryParse(name, ignoreCase: true), and where the difference is the first letter's - // they gave Match one parameter name twice (LAMA0611, CS0100). + // name to TryParse(name, ignoreCase: true), and two whose Match parameters are one name gave + // Match one parameter twice (LAMA0611, CS0100). Each is found by the transform itself, not by + // one standing in for both: ignoring case ordinally left 'Kelvin' and a Kelvin sign's + // 'Kelvin' (U+212A) apart, while the parameters' lower-casing made both 'kelvin'. var generatedNames = ClosedSetTypes.GeneratedNames(target); // A set named as a generated member failed inside the generated code (LAMA0611, CS0542: a @@ -74,11 +76,8 @@ public override void BuildAspect(IAspectBuilder builder) foreach (var member in members.Where(member => generatedNames.Contains(member.Name))) problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, "has the name of a member the generators introduce"))); - foreach (var shared in members.GroupBy(member => member.Name, StringComparer.OrdinalIgnoreCase).Where(group => group.Count() > 1)) - { - foreach (var member in shared.Skip(1)) - problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, $"differs from '{target.Name}.{shared.First().Name}' only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart"))); - } + foreach (var (member, earlier, reason) in ClosedSetTypes.CaseCollisions(members)) + problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, reason.Replace("{0}", $"'{target.Name}.{earlier.Name}'")))); // 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. diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index 79b357e..3b2ac82 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -41,6 +41,37 @@ public static IReadOnlyList GeneratedNames(INamedType set) => /// public static string ParameterName(IField member) => char.ToLowerInvariant(member.Name[0]) + member.Name[1..]; + /// + /// Each member that collides with one declared before it, with that one and why: by + /// TryParse(name, ignoreCase: true)'s comparison (), + /// by , the transform that names Match's parameters, or by both. + /// The reason holds {0} where the earlier member is named. The two transforms differ: the + /// Kelvin sign (U+212A) lower-cases to k, so it names the same parameter as K, while + /// ignoring case ordinally keeps the two apart (it upper-cases, and the sign has no upper case of + /// its own). + /// + public static IEnumerable<(IField Member, IField Earlier, string Reason)> CaseCollisions(IReadOnlyList members) + { + for (var index = 1; index < members.Count; index++) + { + var member = members[index]; + + foreach (var earlier in members.Take(index)) + { + var parse = string.Equals(member.Name, earlier.Name, StringComparison.OrdinalIgnoreCase); + var match = string.Equals(ParameterName(member), ParameterName(earlier), StringComparison.Ordinal); + + if (!parse && !match) continue; + + yield return (member, earlier, + parse && match ? "differs from {0} only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart" + : parse ? "differs from {0} only in case, which TryParse(name, ignoreCase: true) cannot tell apart" + : $"gives Match the parameter name '{ParameterName(member)}' that {{0}} gives it too"); + break; + } + } + } + /// /// Whether is initialized with new of the set where it is declared, /// and with nothing else: new(), new(1, null), new OrderStatus(), each with an diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index dadd5bd..fda16c0 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -391,7 +391,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1018 | A closed set that is generic or nested in a generic type. | | CMTK1019 | A closed set's member not initialized with `new` where it is declared, and with nothing else: `= Open` would give one instance two names, and a member set later, by a call, or by an expression that only starts with `new`, may be another member or null. | | CMTK1020 | A closed set declared as a record: `with` copies a member into an instance that is none, and a record compares by value where members are singletons. | -| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), a set itself named as one of them (a member cannot have the name of its type), or a member whose name differs from another member's only in case: they collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference. | +| CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), a set itself named as one of them (a member cannot have the name of its type), or a member whose name differs from another member's only in case: they collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference; or two members whose `Match` parameters would have one name, which lower-casing the first letter gives a member starting with the Kelvin sign (U+212A) and one starting with `K`. | | CMTK1022 | An abstract type with a value-object marker: it would get no member, and a value held as it, of a type derived from it, would have no `Value` and be written as `{}`. Declare the marker on the sealed type. An abstract closed set is CMTK1014. | The analyzers that come with the base package add CMTK0001 to CMTK0012; see diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 1c122af..8c4d073 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -162,7 +162,7 @@ public void A_declaration_that_cannot_be_generated_is_an_error(string id, string [InlineData("SetWithMemberNamedParse.Parse", "has the name of a member the generators introduce")] [InlineData("SetWithMemberNamedAll.All", "has the name of a member the generators introduce")] [InlineData("SetWithCasedMembers.open", "differs from 'SetWithCasedMembers.Open' only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart")] - [InlineData("SetWithCaseVariants.EXPRESS", "differs from 'SetWithCaseVariants.Express' only in case, which Match's parameters and TryParse(name, ignoreCase: true) cannot tell apart")] + [InlineData("SetWithCaseVariants.EXPRESS", "differs from 'SetWithCaseVariants.Express' only in case, which TryParse(name, ignoreCase: true) cannot tell apart")] public void A_member_name_the_generators_take_is_refused_with_the_reason(string member, string reason) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1021" && error.Message.StartsWith($"'{member}' {reason}, so ") && error.Message.Contains("rename the member"), consumer.Output); @@ -177,6 +177,22 @@ public void A_member_name_the_generators_take_is_refused_with_the_reason(string 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); + /// + /// 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, + /// whose parameters lower-case the first letter: both are kelvin, and the set failed inside + /// the generated code (LAMA0611, CS0100). CMTK1021 groups by each transform. + /// + [Fact] + public void A_member_whose_Match_parameter_another_has_is_refused_with_the_reason() + { + var kelvinSign = ((char)0x212A).ToString(); + + consumer.Errors.ShouldContain(error => error.Id == "CMTK1021" + && error.Message.StartsWith($"'SetWithKelvinSign.{kelvinSign}elvin' gives Match the parameter name 'kelvin' that 'SetWithKelvinSign.Kelvin' gives it too, so ") + && error.Message.Contains("rename the member"), consumer.Output); + } + [Fact] public void A_member_is_never_told_to_be_removed_as_a_hand_written_generated_member() => consumer.Errors.ShouldNotContain(error => error.Id == "CMTK1011" && error.Message.Contains("SetWithMemberNamed"), consumer.Output); @@ -1364,6 +1380,10 @@ public abstract partial class SetAbstract : IClosedSet { } public sealed partial class SetWithCaseVariants : IClosedSet { public static readonly SetWithCaseVariants Express = new(); public static readonly SetWithCaseVariants EXPRESS = new(); } + // The second member's first letter is the Kelvin sign (U+212A), written as an escape: ignoring + // case ordinally tells it from K, and Match's parameters, lower-cased, do not (LAMA0611, CS0100). + public sealed partial class SetWithKelvinSign : IClosedSet { public static readonly SetWithKelvinSign Kelvin = new(); public static readonly SetWithKelvinSign \u212Aelvin = new(); } + public sealed partial class SetWithIgnoreCaseTryParse : IClosedSet { public static readonly SetWithIgnoreCaseTryParse A = new(); public static bool TryParse(string? name, bool ignoreCase, out SetWithIgnoreCaseTryParse? member) { member = A; return true; } } public sealed partial class SetWithIgnoreCaseParse : IClosedSet { public static readonly SetWithIgnoreCaseParse A = new(); public static SetWithIgnoreCaseParse Parse(string name, bool ignoreCase) => A; } From 649ee1ed371a009b310d28652533eca7d1a556db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:45:47 +0200 Subject: [PATCH 126/147] spike: a member Metalama introduces carries no XML documentation, on 2026.1 or 2027.0 preview A template's documentation comment is not copied to the member introduced from it, declaratively or programmatically, and Metalama.Framework has no API that sets one (its only comment API writes a // comment into a body). The consumer's documentation file lists only the members written by hand, also when the aspect library writes its own, and on 2027.0.3-preview too; the repository's woven probes show the same. An undocumented introduced member is no CS1591 for a consumer that documents itself. The IDE's design-time view could not be measured from a command-line build. Generated members stay undocumented in 2.0.0. Co-Authored-By: Claude Opus 5.5 --- .../Directory.Build.props | 11 ++++ .../IntroducedMemberDocs.slnx | 4 ++ spikes/IntroducedMemberDocs/README.md | 50 +++++++++++++++++++ .../Spike.App/Spike.App.csproj | 15 ++++++ .../IntroducedMemberDocs/Spike.App/Target.cs | 7 +++ .../Spike.Aspects/DocumentedAspect.cs | 48 ++++++++++++++++++ .../Spike.Aspects/Spike.Aspects.csproj | 5 ++ 7 files changed, 140 insertions(+) create mode 100644 spikes/IntroducedMemberDocs/Directory.Build.props create mode 100644 spikes/IntroducedMemberDocs/IntroducedMemberDocs.slnx create mode 100644 spikes/IntroducedMemberDocs/README.md create mode 100644 spikes/IntroducedMemberDocs/Spike.App/Spike.App.csproj create mode 100644 spikes/IntroducedMemberDocs/Spike.App/Target.cs create mode 100644 spikes/IntroducedMemberDocs/Spike.Aspects/DocumentedAspect.cs create mode 100644 spikes/IntroducedMemberDocs/Spike.Aspects/Spike.Aspects.csproj diff --git a/spikes/IntroducedMemberDocs/Directory.Build.props b/spikes/IntroducedMemberDocs/Directory.Build.props new file mode 100644 index 0000000..e525bb6 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Directory.Build.props @@ -0,0 +1,11 @@ + + + + net10.0 + 14 + enable + enable + 2026.1.29 + 0.0.1-spike + + diff --git a/spikes/IntroducedMemberDocs/IntroducedMemberDocs.slnx b/spikes/IntroducedMemberDocs/IntroducedMemberDocs.slnx new file mode 100644 index 0000000..c918812 --- /dev/null +++ b/spikes/IntroducedMemberDocs/IntroducedMemberDocs.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/spikes/IntroducedMemberDocs/README.md b/spikes/IntroducedMemberDocs/README.md new file mode 100644 index 0000000..2cb7830 --- /dev/null +++ b/spikes/IntroducedMemberDocs/README.md @@ -0,0 +1,50 @@ +# Spike: can a generated member carry XML documentation, on Metalama 2026.1? + +**Status: run 2026-10-13, answered: no.** Asked for 2.0.0: the generated members (`From`, `TryFrom`, +`FromKnownGood`, `Parse`, `TryParse`, `Value`, `CompareTo`, the operators, a closed set's `Match`, +`All`, `Name`) carry no XML documentation, so IntelliSense shows nothing for them. + +**Question.** Does a member a Metalama aspect introduces carry the XML documentation comment of its +template, or can an aspect give it one, and does that documentation reach (a) the consumer's compiled +XML documentation file, which a referencing project's IntelliSense reads, and (b) the IDE's +design-time view of the consumer's own project? + +**Answer: neither, on Metalama 2026.1.29 nor on 2027.0.3-preview (SDK 10.0.401).** A template's +documentation comment is not copied to the member introduced from it, whether introduced +declaratively (`[Introduce]`) or programmatically (`IntroduceMethod`, `IntroduceProperty`), and the +framework has no API that sets one: the public API of both versions, searched in its XML +documentation, has no member about documentation, and the only comment API, `meta.InsertComment`, +writes a `//` comment into a body. The consumer's documentation file lists the members written by hand +and none of the introduced ones. Supporting it would need either Metalama to copy or accept +documentation, or a step of our own that rewrites the consumer's documentation file after the build; +the second is a hand-rolled workaround that would also miss the IDE's view of the consumer's own +project, so it is not done. + +## The candidate + +`Spike.Aspects` holds `DocumentedAspect`, which introduces a method and a property declaratively and +a method, a property and a nested class programmatically, each template with a `///` comment. +`Spike.App` applies it to a documented `Target`, with `GenerateDocumentationFile` and +`MetalamaEmitCompilerTransformedFiles`. `dotnet build IntroducedMemberDocs.slnx`, then read +`Spike.App/bin/Debug/net10.0/Spike.App.xml` and `Spike.App/obj/Debug/net10.0/metalama/Target.cs`. +`-p:MetalamaVersion=2027.0.3-preview` measures the preview. + +## Findings + +| # | Claim | Result | +|---|---|---| +| 1 | The woven `Target` holds the four introduced members and the nested class | measured: all five, in `obj/.../metalama/Target.cs` | +| 2 | An introduced member carries its template's `///` comment | measured: no; the only `///` in the woven file is the hand-written one on `Target` | +| 3 | The consumer's XML documentation file documents an introduced member | measured: no; one ``, `T:Spike.App.Target` | +| 4 | An aspect can set a member's documentation | no such API in Metalama.Framework 2026.1.29 or 2027.0.3-preview (their XML documentation has no member about documentation; `meta.InsertComment` writes a `//` comment in a body) | +| 5 | Metalama 2027.0.3-preview copies the template's comment | measured: no, as 2 and 3 | +| 6 | An undocumented introduced public member is a CS1591 in a consumer with `GenerateDocumentationFile` | measured: no warning at all, so a consumer that treats warnings as errors builds | +| 7 | The IDE's design-time view shows documentation for an introduced member | not measured: the design-time pipeline runs in the IDE's process, not in a command-line build. With no documentation at compile time and no API to give one, nothing suggests it would | +| 8 | It changes when the aspect library writes its own documentation file (`-p:GenerateDocumentationFile=true`) | measured: no; the library's file documents its five templates, the consumer's still one member | +| 9 | The same holds for this repository's generators, whose templates are documented and whose project writes a documentation file | measured in the woven probes (`test/CodoMetis.TypeKit.Generators.Probes/obj/.../metalama/Probes.cs`): `From` has none of its template's "Wraps any value but null", a closed set's `Parse` none of "A FormatException naming the set" | + +## What it decided + +Generated members stay undocumented in 2.0.0. The roadmap's 2.1 line: ask Metalama for documentation +on introduced declarations (copied from the template, or set through the builder), and document every +generated member once it exists, with a test that reads a consumer's documentation file. diff --git a/spikes/IntroducedMemberDocs/Spike.App/Spike.App.csproj b/spikes/IntroducedMemberDocs/Spike.App/Spike.App.csproj new file mode 100644 index 0000000..9adec98 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.App/Spike.App.csproj @@ -0,0 +1,15 @@ + + + + Library + true + true + + + + + diff --git a/spikes/IntroducedMemberDocs/Spike.App/Target.cs b/spikes/IntroducedMemberDocs/Spike.App/Target.cs new file mode 100644 index 0000000..91cd027 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.App/Target.cs @@ -0,0 +1,7 @@ +using Spike.Aspects; + +namespace Spike.App; + +/// The type the aspect introduces into. Documented by hand, so it is not a CS1591 itself. +[DocumentedAspect] +public partial class Target; diff --git a/spikes/IntroducedMemberDocs/Spike.Aspects/DocumentedAspect.cs b/spikes/IntroducedMemberDocs/Spike.Aspects/DocumentedAspect.cs new file mode 100644 index 0000000..cdd3ec4 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.Aspects/DocumentedAspect.cs @@ -0,0 +1,48 @@ +using Metalama.Framework.Aspects; +using Metalama.Framework.Code; + +namespace Spike.Aspects; + +/// +/// Introduces one member of each kind, each from a template that carries an XML documentation +/// comment, declaratively and programmatically, so the build shows which of them reach the +/// consumer's woven code and its documentation file. +/// +public sealed class DocumentedAspect : TypeAspect +{ + /// Declarative method: introduced by [Introduce]. + /// One. + [Introduce] + public static int DeclaredMethod() => 1; + + /// Declarative property: introduced by [Introduce]. + [Introduce] + public static int DeclaredProperty => 2; + + /// Programmatic method: introduced by IntroduceMethod. + /// The value. + /// The value. + [Template] + public static int ProgrammaticMethodTemplate(int value) => value; + + /// Programmatic property: introduced by IntroduceProperty. + [Template] + public int ProgrammaticPropertyTemplate => 3; + + public override void BuildAspect(IAspectBuilder builder) + { + builder.IntroduceMethod(nameof(ProgrammaticMethodTemplate), IntroductionScope.Static, buildMethod: m => + { + m.Name = "ProgrammaticMethod"; + m.Accessibility = Accessibility.Public; + }); + + builder.IntroduceProperty(nameof(ProgrammaticPropertyTemplate), buildProperty: p => + { + p.Name = "ProgrammaticProperty"; + p.Accessibility = Accessibility.Public; + }); + + builder.IntroduceClass("NestedConverter", buildType: t => t.Accessibility = Accessibility.Public); + } +} diff --git a/spikes/IntroducedMemberDocs/Spike.Aspects/Spike.Aspects.csproj b/spikes/IntroducedMemberDocs/Spike.Aspects/Spike.Aspects.csproj new file mode 100644 index 0000000..03683b7 --- /dev/null +++ b/spikes/IntroducedMemberDocs/Spike.Aspects/Spike.Aspects.csproj @@ -0,0 +1,5 @@ + + + + + From 390b0fc5a116d8be4bb102352e9d9aad47a00f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:44:15 +0200 Subject: [PATCH 127/147] fix: an inlined nullable closed set admits null in the OpenAPI document, as a referenced one does Where the host inlines closed sets, a nullable set property was the bare string enum, so a validator or a generated client refused the null the application reads, while a referenced one admits it around its reference (decision 36). Inlined, the property's own schema now admits null in its type and among its values, in OpenAPI 3.1 and 3.0; the shared component never does. Elements and parameters stay as a referenced set's, which ASP.NET describes without null. Co-Authored-By: Claude Opus 5.5 --- design/closed-sets.md | 2 +- .../ClosedSetSchemaTransformer.cs | 17 ++++-- src/CodoMetis.TypeKit.AspNetCore/README.md | 3 ++ .../ValueObjectSchemaTransformer.cs | 2 +- .../ClosedSetSchemaTests.cs | 53 +++++++++++++++++++ .../PatchSchemaTests.cs | 2 +- 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/design/closed-sets.md b/design/closed-sets.md index f9b6c33..bbb148c 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -41,7 +41,7 @@ The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). |---|---|---| | JSON | The name, as a value and as a dictionary key; no naming policy renames it, since a renamed name would not read back | `ClosedSetTests` | | EF Core | A text column of the name through `ClosedSetConverter`, found by the type-mapping plugin through the discovery attribute, as a value object is ([EF Core](ef-core.md)); an unknown stored name throws when read | `ClosedSetMappingTests` | -| OpenAPI | A string enum of the names, as a component named after the nesting chain ([decision 24](adr/0024-nested-value-objects-named-after-nesting.md)), in every position a parameter or a property can take | `ClosedSetSchemaTests`, `ClosedSetBindingTests` | +| OpenAPI | A string enum of the names, as a component named after the nesting chain ([decision 24](adr/0024-nested-value-objects-named-after-nesting.md)), in every position a parameter or a property can take; a nullable property admits null, inlined too ([decision 36](adr/0036-nullable-value-object-admits-null-openapi.md)) | `ClosedSetSchemaTests`, `ClosedSetBindingTests` | | Fault responses | An `IFaultSet` member carries a `FaultKind` and an optional description, and `AddFaultResponses()` maps every member by kind ([decision 49](adr/0049-fault-responses-client-can-act.md)) | `FaultResponseTests` | | Serilog | Logged as what it prints, never destructured into its constant data ([decision 41](adr/0041-sensitive-value-object-declares-tostring-serilog.md)) | the Serilog tests | diff --git a/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs index c9250b7..a9d1564 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ClosedSetSchemaTransformer.cs @@ -34,13 +34,19 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC if (Describe(info.Type) is { } set) { - Fill(schema, set); + // Where the set is a component, ASP.NET puts the null of a nullable property around the reference. + // Inlined, the schema is this property's own, and the null goes in it (decision 36), as ASP.NET + // puts it in an inlined nullable string's type and an inlined nullable enum's values. + var admitsNull = context.JsonPropertyInfo is { IsGetNullable: true } or { IsSetNullable: true } + && ValueObjectSchemaTransformer.SchemaReferenceId(info, context) is null; + + Fill(schema, set, admitsNull); return; } if (context is { JsonPropertyInfo: null, ParameterDescription: { } parameter } && ParameterSet(parameter) is { } parameterSet) { - Fill(schema, parameterSet); + Fill(schema, parameterSet, admitsNull: false); return; } @@ -53,12 +59,15 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC schema.AdditionalProperties = await context.GetOrCreateSchemaAsync(element, parameterDescription: null, cancellationToken); } - private static void Fill(OpenApiSchema schema, GeneratedClosedSetAttribute set) + private static void Fill(OpenApiSchema schema, GeneratedClosedSetAttribute set, bool admitsNull) { schema.Properties?.Clear(); schema.Required?.Clear(); - schema.Type = JsonSchemaType.String; + schema.Type = admitsNull ? JsonSchemaType.String | JsonSchemaType.Null : JsonSchemaType.String; schema.Enum = [.. set.Names.Select(name => (System.Text.Json.Nodes.JsonNode)name)]; + + // An enum lists every value a validator accepts, so the null is one of them too. + if (admitsNull) schema.Enum.Add(null!); } /// The set a parameter binds to: minimal APIs name it in the parameter's type, MVC in its model metadata. diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index eb68dee..52fe42c 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -95,6 +95,9 @@ no component at all), and both parameters are a bare `string`. : OpenApiOptions.CreateDefaultSchemaReferenceId(type); ``` + Inlined, a nullable value object or closed set admits null in its own schema wherever a referenced + one admits it around its reference: a closed set's property is then the enum of its names and null. + - **Rules belong in `Create`, not on the property.** A validation attribute on a value-object property (`[MaxLength]`, `[Range]`) lands in the value object's shared component, as ASP.NET does for any referenced schema, so it then applies to every other use too. `Create` is where every way diff --git a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs index 5fb88b2..662b831 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs @@ -127,7 +127,7 @@ private static async Task ElementSchemaAsync(Type element, OpenA /// The component a type gets in this document, or where the host inlines it: the /// document's own , which ASP.NET asks too. /// - private static string? SchemaReferenceId(JsonTypeInfo type, OpenApiSchemaTransformerContext context) => + internal static string? SchemaReferenceId(JsonTypeInfo type, OpenApiSchemaTransformerContext context) => context.ApplicationServices.GetRequiredService>().Get(context.DocumentName).CreateSchemaReferenceId(type); /// diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs index 6c91779..3555ae2 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/ClosedSetSchemaTests.cs @@ -108,6 +108,59 @@ public async Task An_MVC_closed_set_parameter_is_the_string_enum_too(OpenApiSpec } } + public sealed record Assignment(ProbeStatus? Status, ProbeStatus Required, List History, Dictionary ByName); + + /// + /// A nullable set admits null wherever a referenced one does, also where the host inlines sets + /// (decision 36). Inlined, the property was the bare enum, and a validator or a generated client + /// refused the null the application reads. Elements and parameters admit what a referenced set's + /// do: ASP.NET describes no null there, as for a List<string?>. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task An_inlined_closed_set_admits_null_wherever_a_referenced_one_does(OpenApiSpecVersion version) + { + var referenced = await AssignmentsAsync(version, inlined: false); + var inlined = await AssignmentsAsync(version, inlined: true); + + inlined.Property(nameof(Assignment), "status").ToJsonString().ShouldNotContain("$ref", customMessage: "the set is not inlined"); + PatchSchemaTests.Validator(inlined, inlined.Property(nameof(Assignment), "status"), version)("null") + .ShouldBeTrue($"an inlined nullable set refuses null: {inlined.Property(nameof(Assignment), "status").ToJsonString()}"); + + foreach (var (property, values) in new (string, string[])[] + { + ("status", ["null", "\"Open\"", "\"Nope\""]), + ("required", ["null", "\"Open\"", "\"Nope\""]), + ("history", ["[\"Open\"]", "[null]", "[\"Nope\"]"]), + ("byName", ["{\"a\":\"Open\"}", "{\"a\":null}", "{\"a\":\"Nope\"}"]), + }) + { + var asReferenced = PatchSchemaTests.Validator(referenced, referenced.Property(nameof(Assignment), property), version); + var asInlined = PatchSchemaTests.Validator(inlined, inlined.Property(nameof(Assignment), property), version); + + foreach (var value in values) + asInlined(value).ShouldBe(asReferenced(value), $"'{property}' inlined judges {value} unlike referenced: {inlined.Property(nameof(Assignment), property).ToJsonString()}"); + } + + inlined.Parameter("/assignments", "get", "status").ShouldDescribeTheSameAs(referenced.Parameter("/assignments", "get", "status"), "the status parameter"); + } + + private static Task AssignmentsAsync(OpenApiSpecVersion version, bool inlined) => + ProbeHost.DocumentAsync( + openApi => + { + openApi.AddTypeKit(); + if (inlined) + openApi.CreateSchemaReferenceId = type => + ClosedSetSchemaTransformer.Describe(type.Type) is null ? Microsoft.AspNetCore.OpenApi.OpenApiOptions.CreateDefaultSchemaReferenceId(type) : null; + }, + version: version, + endpoints: app => + { + app.MapPost("/assignments", int (Assignment assignment) => 0); + app.MapGet("/assignments", int (ProbeStatus? status) => 0); + }); + /// Two nested sets of one simple name shared one component, named after the first described, whose names then stood for both. [Fact] public async Task Two_nested_sets_of_one_simple_name_are_two_components() diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 5ffef39..e9fb8d1 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -122,7 +122,7 @@ public async Task A_patch_accepts_null_and_otherwise_exactly_what_its_argument_a /// nullable read as 3.0.3 defines it: null added to a type the same schema states, and /// nothing where it states none. /// - private static Func Validator(OpenApiJson document, JsonNode schema, OpenApiSpecVersion version) + internal static Func Validator(OpenApiJson document, JsonNode schema, OpenApiSpecVersion version) { var inlined = Inline(document, schema.DeepClone()); if (version == OpenApiSpecVersion.OpenApi3_0) ReadNullable(inlined); From 3121d03ddee1706063609b0ac15474660acaa43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:47:42 +0200 Subject: [PATCH 128/147] fix: a problem type is written as the URI it is, its escapes kept The problem's type and the document's list of problem types wrote FaultResponse.Type with Uri.ToString(), which unescapes: https://example.com/problems/a%20b went out with a space, an escaped non-ASCII path as the letter, so the type was no URI. An absolute type is now written as Uri.AbsoluteUri, which keeps an escape escaped and escapes an unescaped space, and a relative one as given, once where the faults are mapped. Co-Authored-By: Claude Opus 5.5 --- .../FaultProblem.cs | 2 +- .../FaultResponse.cs | 13 ++++++++ .../FaultResponses.cs | 17 ++++++----- src/CodoMetis.TypeKit.AspNetCore/README.md | 2 ++ .../FaultResponsePartsTests.cs | 30 +++++++++++++++++++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs index d273e0c..5661ac6 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs @@ -62,7 +62,7 @@ public async Task ExecuteAsync(HttpContext httpContext) { await TypedResults.Problem( statusCode: status, - type: answer.Type?.ToString(), + type: answer.Type, title: answer.Title, detail: detail, extensions: new Dictionary { [FaultResponsesOperationTransformer.FaultMember] = name }) diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs index 7f23b51..4057692 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponse.cs @@ -64,6 +64,11 @@ private FaultResponse(int statusCode, Uri? type, string? title) }); /// The same response with the problem's type, a URI a client matches on. + /// + /// The problem and the document write an absolute URI as , escaped as + /// RFC 3986 has it (a%20b stays a%20b, and an unescaped space or non-ASCII letter is escaped), and + /// a relative one as given. would unescape it into no URI at all. + /// /// The problem type's URI. /// A new response. public FaultResponse WithType(Uri type) @@ -72,6 +77,14 @@ public FaultResponse WithType(Uri type) return new(StatusCode, type, Title); } + /// + /// A problem type as the response and the document write it: RFC 9457 makes type a URI + /// reference, so an absolute URI is , which keeps an escape escaped, + /// and a relative one is as given. Never , which unescapes %20 and + /// an escaped non-ASCII character. + /// + internal static string Written(Uri type) => type.IsAbsoluteUri ? type.AbsoluteUri : type.OriginalString; + /// The same response with the problem's title, a summary of the problem type a person reads. /// The title. Never blank. /// A new response. diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs index 3d14c27..06b4c7c 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultResponses.cs @@ -61,7 +61,7 @@ public static FaultResponses Map(FaultType type, Func.DescriptionOf(fault)); + answers[fault] = new Answer(response.StatusCode, response.Type is { } problemType ? FaultResponse.Written(problemType) : null, response.Title, type.NameOf(fault), type.QualifiedNameOf(fault), FaultType.DescriptionOf(fault)); } return new FaultResponses(type, answers); @@ -83,18 +83,21 @@ public IReadOnlyList StatusesOf(IReadOnlyList endpointMetadata) => [.. Type.Members.Select(fault => FaultStatuses.Override(endpointMetadata, fault, Type) ?? _answers[fault].Status).Distinct().Order()]; /// The problem types of the members an endpoint answers with , for that response's description. - public IReadOnlyList TypesOf(int status, IReadOnlyList endpointMetadata) => + public IReadOnlyList TypesOf(int status, IReadOnlyList endpointMetadata) => [.. Type.Members.Where(fault => (FaultStatuses.Override(endpointMetadata, fault, Type) ?? _answers[fault].Status) == status) .Select(fault => _answers[fault].Type) - .OfType() + .OfType() .Distinct()]; public static InvalidOperationException Unmapped() => new($"No HTTP status is mapped for the fault type {FaultTypes.NameOf(typeof(TFault))}. " + $"Map each of its members with services.AddFaultResponses<{FaultTypes.NameOf(typeof(TFault))}>(fault => …)." + FaultRefusals.Mapping); - /// The mapped answer for one member: what the problem says, before an endpoint changes its status. - internal sealed record Answer(int Status, Uri? Type, string? Title, string Name, string QualifiedName, string? Description); + /// + /// The mapped answer for one member: what the problem says, before an endpoint changes its status. Its + /// Type is the problem's type as the response and the document write it (). + /// + internal sealed record Answer(int Status, string? Type, string? Title, string Name, string QualifiedName, string? Description); } /// @@ -111,7 +114,7 @@ internal abstract class FaultResponsesMetadata public abstract IReadOnlyList? Statuses(IServiceProvider services, IReadOnlyList endpointMetadata); /// The problem types listed for . - public abstract IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata); + public abstract IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata); /// What is wrong with the endpoint's status overrides for this fault type: a name no member has. public abstract IEnumerable Problems(IServiceProvider services, IReadOnlyList endpointMetadata); @@ -130,7 +133,7 @@ private FaultResponsesMetadata() { } public override IReadOnlyList? Statuses(IServiceProvider services, IReadOnlyList endpointMetadata) => Responses(services)?.StatusesOf(endpointMetadata); - public override IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata) => + public override IReadOnlyList Types(IServiceProvider services, int status, IReadOnlyList endpointMetadata) => Responses(services)?.TypesOf(status, endpointMetadata) ?? []; public override IEnumerable Problems(IServiceProvider services, IReadOnlyList endpointMetadata) diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 52fe42c..eb8ef80 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -202,6 +202,8 @@ A bare status converts to a `FaultResponse`, so the arms that need nothing else is called. The document's problem response for a status lists the problem types answered with it in its description: `Payment Required. Problem types: https://example.com/problems/declined`. +Both write the type as the URI it is: an escape stays escaped (`/problems/a%20b`, `%C3%BC`), an +absolute URI given unescaped is escaped, and a relative one is written as given. ### A closed set as the fault type diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs index 6a67e06..b779f12 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs @@ -108,6 +108,36 @@ public async Task A_type_and_a_title_reach_the_problem_and_the_document() conflict["description"]!.GetValue().ShouldBe($"Conflict. Problem types: {NameTakenType}"); } + public enum TypedFault { Escaped } + + /// + /// A problem type is written as the URI it is: unescaped %20 and an + /// escaped non-ASCII character, and the problem's type was then no URI at all, in the response + /// and in the document's description alike. + /// + [Theory] + [InlineData("https://example.com/problems/a%20b", "https://example.com/problems/a%20b")] + [InlineData("https://example.com/problems/%C3%BCber", "https://example.com/problems/%C3%BCber")] + [InlineData("https://example.com/problems/a%2Fb", "https://example.com/problems/a%2Fb")] + [InlineData("urn:example:problems:a%20b", "urn:example:problems:a%20b")] + [InlineData("https://example.com/problems/a b", "https://example.com/problems/a%20b")] // given unescaped: written as a URI + [InlineData("/problems/a%20b", "/problems/a%20b")] // relative: as given + public async Task A_problem_type_is_written_with_its_escapes(string given, string written) + { + var type = new Uri(given, UriKind.RelativeOrAbsolute); + await using var app = await StartAsync( + services: services => + { + Responses(services); + services.AddFaultResponses(_ => FaultResponse.Status(StatusCodes.Status409Conflict).WithType(type)); + }, + endpoints: app => app.MapGet("/typed", () => Result.Error(TypedFault.Escaped).ToHttpResult())); + + (await ProblemAsync(app, "/typed", HttpStatusCode.Conflict))["type"]!.GetValue().ShouldBe(written); + (await DocumentAsync(app))["paths"]!["/typed"]!["get"]!["responses"]!["409"]!["description"]!.GetValue() + .ShouldBe($"Conflict. Problem types: {written}"); + } + // 3: ToHttpResult over Result>, Result and a closed set's resolves without type arguments. [Fact] public void ToHttpResult_resolves_for_an_enum_a_fault_and_a_closed_set_without_type_arguments() From 800620f18df2eebb42e26c5402f07f4294821050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:51:35 +0200 Subject: [PATCH 129/147] docs: a fault's name reaches the client, and causes it must not tell apart are mapped to one fault Every problem ToHttpResult() writes names its member in fault, with a description or without, so a sign-in that answers 401 for an unknown user and a wrong password still says which. The AspNetCore README, docs/aspnet-core.md and docs/logging-and-secrets.md now say the name is part of the response's contract, and show mapping both causes to one member before ToHttpResult(), as compiled code with a test that the two then answer alike. An opt-in that withholds the name is for 2.1 (decision 64). Co-Authored-By: Claude Opus 5.5 --- ...-client-causes-not-told-apart-one-fault.md | 25 ++++++ design/adr/README.md | 1 + docs/aspnet-core.md | 7 ++ docs/logging-and-secrets.md | 8 ++ src/CodoMetis.TypeKit.AspNetCore/README.md | 21 +++++ .../AspNetCore/FaultResponseSamples.cs | 83 +++++++++++++++++++ 6 files changed, 145 insertions(+) create mode 100644 design/adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md diff --git a/design/adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md b/design/adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md new file mode 100644 index 0000000..5889289 --- /dev/null +++ b/design/adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md @@ -0,0 +1,25 @@ +# 0064. A fault's name reaches the client; causes it must not tell apart are one fault + +Date: 2026-10-13 +Status: accepted + +Context: 2026-10-13, the pre-tag review of 2.0.0; [decision 38](0038-result-becomes-response-explicit-tohttpresult-faults.md), +[decision 49](0049-fault-responses-client-can-act.md). + +Every problem `ToHttpResult()` writes carries `fault`, its member's name ([decision 38](0038-result-becomes-response-explicit-tohttpresult-faults.md), +since 1.5), for a member without a description too, and the document describes it +([decision 49](0049-fault-responses-client-can-act.md)). The review named the case that matters: a +sign-in that answers 401 both for an unknown user and for a wrong password still says which, and so +which accounts exist. + +For 2.0.0 the name stays in every fault response, and the package READMEs and `docs/` say it is part +of the response's contract. Where two causes must not be told apart, the boundary maps them to one +member of its own fault type before `ToHttpResult()` (`MapError(_ => LoginFault.InvalidCredentials)`); +the AspNetCore README shows it as compiled code, and `SignInSampleTests` holds that the two then +answer alike. It needs no API, and it puts the choice where the meaning is: the boundary's fault type +says what the client may know. + +An opt-in that withholds the name for chosen members or a whole fault type would be additive, and +joins 2.1. Rejected for 2.0.0: withholding it by default, since `fault` is what a client matches on +and 1.5's responses carried it; and withholding it for a member without a description, since a +description is for people and says nothing about whether the name may be known. diff --git a/design/adr/README.md b/design/adr/README.md index 0dd4940..0858c78 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -70,3 +70,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0061](0061-scope-s-fault-status-applies-where-fault-type-answered.md) | A scope's fault status applies where its fault type is answered | 2026-10-13 | accepted | | [0062](0062-asynchronous-callbacks-run-where-awaited-task-completed.md) | An asynchronous callback runs where the task it waited for completed | 2026-10-13 | accepted | | [0063](0063-char-keys-application-assigned-ulong-documented.md) | A key over `char` is the application's to assign; a default key over `ulong` throws at `Add` | 2026-10-13 | accepted | +| [0064](0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md) | A fault's name reaches the client; causes it must not tell apart are one fault | 2026-10-13 | accepted | diff --git a/docs/aspnet-core.md b/docs/aspnet-core.md index 915e063..80263d3 100644 --- a/docs/aspnet-core.md +++ b/docs/aspnet-core.md @@ -163,6 +163,13 @@ such as a `201 Created`, and the fault on the request for a `CustomizeProblemDet [Mapping faults to statuses](../src/CodoMetis.TypeKit.AspNetCore/README.md#mapping-faults-to-statuses) and the sections after it show each. +Every problem names its fault in `fault`, for every member, with a description or without one: the +name reaches the client as the status does. Where two causes must not be told apart, an unknown user +and a wrong password, which would say which accounts exist, map them to one member before +`ToHttpResult()`, as the README's +[Faults a client must not tell apart](../src/CodoMetis.TypeKit.AspNetCore/README.md#faults-a-client-must-not-tell-apart) +shows. + ## The document With `AddTypeKit()`, every value object is the schema ASP.NET publishes for the type it wraps, diff --git a/docs/logging-and-secrets.md b/docs/logging-and-secrets.md index 7ade620..63ced4d 100644 --- a/docs/logging-and-secrets.md +++ b/docs/logging-and-secrets.md @@ -112,3 +112,11 @@ A body refused by the JSON converter does not System.Text.Json's: a value object used as a dictionary key is read from the property name, and the `JsonException` for a refused or unreadable key carries the key in its `Path` (`$.items.SE`), which MVC uses as the error's name in its 400. Keep a secret out of a dictionary's keys. + +## What a fault response tells the client + +A fault answered with `ToHttpResult()` names its member in the problem's `fault`, for every member, +whatever its status or description: the name is meant for the client. Two refusals the client must +not tell apart, an unknown user and a wrong password, therefore become one fault before the response, +as [ASP.NET Core](../src/CodoMetis.TypeKit.AspNetCore/README.md#faults-a-client-must-not-tell-apart) +shows; the domain keeps both for its log. diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index eb8ef80..fa8367d 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -337,6 +337,27 @@ responses refer to as well, so the description also says that only a problem ans carries it. It stays a string, since an enum of every fault type would change every operation when one is added. A document without a fault response keeps the schema as ASP.NET writes it. +### Faults a client must not tell apart + +Every problem `ToHttpResult()` writes names its fault in `fault`, for every member, whether it has a +description or not: the name is part of the response's contract, as its status is, and the document +describes it. A status alone hides nothing. A sign-in that answers 401 for an unknown user and for a +wrong password still says `UnknownUser` or `WrongPassword`, and so which accounts exist. Where two +causes must not be told apart, make them one fault before `ToHttpResult()`, mapping the domain's +faults to a member of the boundary's own: + +```csharp +public enum SignInFault { UnknownUser, WrongPassword } // the domain's: which check refused +public enum LoginFault { InvalidCredentials } // what the client is told + +builder.Services.AddFaultResponses(_ => StatusCodes.Status401Unauthorized); + +app.MapPost("/login", (Credentials credentials) => accounts.SignIn(credentials).MapError(_ => LoginFault.InvalidCredentials).ToHttpResult()); +``` + +Both causes then answer alike, status, `type`, `title`, `fault` and `detail`. The domain keeps its two +faults for its own use, a log or a lockout count; a `Fault`'s detail stays behind with them. + ## NodaTime ASP.NET cannot see through NodaTime's JSON converters, so once they are on the host's JSON options diff --git a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs index d3b0bdc..4569a5f 100644 --- a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs @@ -42,6 +42,39 @@ public interface IAccounts Result> Rename(int id, string email); } +// sample: CodoMetis.TypeKit.AspNetCore/one-fault-for-two-causes +public enum SignInFault { UnknownUser, WrongPassword } // the domain's: which check refused +public enum LoginFault { InvalidCredentials } // what the client is told + +// end sample +public sealed record Credentials(string User, string Password); + +public sealed record Session(string Token); + +public interface ISignIn +{ + Result SignIn(Credentials credentials); +} + +/// The README's sign-in: the domain tells its two refusals apart, the response does not. +public static class SignIns +{ + public static void Responses(WebApplicationBuilder builder) + { + // sample: CodoMetis.TypeKit.AspNetCore/one-fault-for-two-causes + builder.Services.AddFaultResponses(_ => StatusCodes.Status401Unauthorized); + + // end sample + } + + public static void Endpoints(WebApplication app, ISignIn accounts) + { + // sample: CodoMetis.TypeKit.AspNetCore/one-fault-for-two-causes + app.MapPost("/login", (Credentials credentials) => accounts.SignIn(credentials).MapError(_ => LoginFault.InvalidCredentials).ToHttpResult()); + // end sample + } +} + public static class Faults { public static void Responses(WebApplicationBuilder builder) @@ -230,3 +263,53 @@ public async Task A_type_URI_reaches_the_problem_and_the_document() .ShouldBe("Payment Required. Problem types: https://example.com/problems/declined"); } } + +/// +/// What the README's "Faults a client must not tell apart" says: one status for two causes still names +/// which it was, and mapped to one member at the boundary, the two answer alike, the trace id aside. +/// +public sealed class SignInSampleTests +{ + private sealed class Refusing : ISignIn + { + public Result SignIn(Credentials credentials) => + Result.Error(credentials.User == "ann" ? SignInFault.WrongPassword : SignInFault.UnknownUser); + } + + [Fact] + public async Task Two_causes_mapped_to_one_fault_answer_alike_where_one_status_alone_tells_them_apart() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + SignIns.Responses(builder); + builder.Services.AddFaultResponses(_ => StatusCodes.Status401Unauthorized); + + await using var app = builder.Build(); + var accounts = new Refusing(); + SignIns.Endpoints(app, accounts); + app.MapPost("/login-telling", (Credentials credentials) => accounts.SignIn(credentials).ToHttpResult()); // one status, the domain's faults + await app.StartAsync(TestContext.Current.CancellationToken); + + var unknownUser = await AnswerAsync(app, "/login", "nobody"); + var wrongPassword = await AnswerAsync(app, "/login", "ann"); + unknownUser.Status.ShouldBe(HttpStatusCode.Unauthorized); + unknownUser.Body["fault"]!.GetValue().ShouldBe("InvalidCredentials"); + wrongPassword.Status.ShouldBe(unknownUser.Status); + JsonNode.DeepEquals(wrongPassword.Body, unknownUser.Body) + .ShouldBeTrue($"{wrongPassword.Body.ToJsonString()} tells itself apart from {unknownUser.Body.ToJsonString()}"); + + (await AnswerAsync(app, "/login-telling", "nobody")).Body["fault"]!.GetValue().ShouldBe("UnknownUser"); + (await AnswerAsync(app, "/login-telling", "ann")).Body["fault"]!.GetValue().ShouldBe("WrongPassword"); + } + + private static async Task<(HttpStatusCode Status, JsonObject Body)> AnswerAsync(WebApplication app, string path, string user) + { + using var content = new StringContent($$"""{"user":"{{user}}","password":"secret"}""", System.Text.Encoding.UTF8, "application/json"); + using var response = await app.GetTestClient().PostAsync(path, content, TestContext.Current.CancellationToken); + var body = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!.AsObject(); + body.Remove("traceId"); + + return (response.StatusCode, body); + } +} From 9534751227fb14183a8a7d7be89c9219e2ef3b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:54:08 +0200 Subject: [PATCH 130/147] fix: a nullable value object over an inlined enum admits null among the enum's values Where the host inlines the wrapped enum as well as the value object, a nullable value object over an enum written by name was the enum without null, where ASP.NET writes a nullable enum property with null among its values (decision 36); the null went into the type alone, and a by-name enum has none. The inlined schema now lists null among its values too, which also covers a value object over an inlined closed set. Co-Authored-By: Claude Opus 5.5 --- .../ValueObjectSchemaTransformer.cs | 5 ++- .../InlinedValueObjectTests.cs | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs index 662b831..d22064f 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/ValueObjectSchemaTransformer.cs @@ -202,9 +202,12 @@ private static async Task DescribeAsync( else { // An inlined wrapped type (a number, a string, a Guid) admits null in its type, as ASP.NET's - // exporter writes a nullable property of it. + // exporter writes a nullable property of it; an inlined enum or closed set among its values + // too, since an enum lists every value a validator accepts. A new list: the wrapped one is + // the wrapped type's. schema.FillFrom(wrappedSchema); if (schema.Type is { } type) schema.Type = type | JsonSchemaType.Null; + if (schema.Enum is { Count: > 0 } values && !values.Contains(null!)) schema.Enum = [.. values, null!]; } } catch (NotSupportedException unsupported) diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs index eccf9d9..584be04 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/InlinedValueObjectTests.cs @@ -64,6 +64,40 @@ public async Task An_inlined_value_object_is_described_as_its_wrapped_type_in_th .ShouldDescribeTheSameAs(Inline(document, document.Property("ControlDocument", property))!, property); } + public static TheoryData EnumProperties() + { + var data = new TheoryData(); + foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0 }) + foreach (var property in new[] { "maybeWeekday", "weekday" }) + foreach (var enumsByName in new[] { false, true }) + data.Add(property, version, enumsByName); + + return data; + } + + /// + /// Where the host inlines the wrapped enum too, a nullable value object over it admits null among the + /// enum's values, as ASP.NET writes a nullable enum: it got null in its type at most, and an enum by + /// name, which has no type, refused the null outright. + /// + [Theory] + [MemberData(nameof(EnumProperties))] + public async Task A_value_object_over_an_inlined_enum_is_described_as_the_enum_in_the_same_position(string property, OpenApiSpecVersion version, bool enumsByName) + { + var document = await ProbeHost.DocumentAsync( + openApi => + { + openApi.AddTypeKit(); + openApi.CreateSchemaReferenceId = type => type.Type == typeof(ProbeDocument) || type.Type == typeof(ControlDocument) ? type.Type.Name : null; + }, + enumsByName ? json => json.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) : null, + version); + + var inlined = document.Property("ProbeDocument", property); + inlined.ToJsonString().ShouldNotContain("$ref", customMessage: $"'{property}' is not inlined"); + inlined.ShouldDescribeTheSameAs(document.Property("ControlDocument", property), property); + } + /// A second AddTypeKit() visits what the first made, the null-or-content schemas included. [Fact] public async Task Adding_it_twice_to_an_inlining_host_publishes_the_same_document() From 0d6ba1a475a3a4e0affb571778773f32ea80ef82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:44:27 +0200 Subject: [PATCH 131/147] fix: CMTK0010 accepts an enum and a delegate as concrete, and a constraint that proves the attribute Before it ships as a frozen Error, two kinds of false report are removed. [Concrete] refused an enum, a delegate and dynamic. An enum is a value type, which new() and struct accept, and a struct-constrained parameter that proves [Concrete] could already be one; a delegate is a sealed class, made from a target and a method as a class with a parameterised constructor is made from its arguments; dynamic is object. Their abstract bases (Enum, Delegate, MulticastDelegate, ValueType) are still refused. A forwarded type parameter had to carry the attribute even where its own constraints leave no breaking argument. Now a bound that is or proves a record proves [Record] (only records derive from records, CS8865); a bound proving [Sealed] proves [Sealed] (a sealed type has no other subtype); whatever proves [Sealed], the attribute included, proves [Concrete] (a sealed type is never abstract). Nothing but the attribute proves [Abstract]. No missed form was found among the neighbours measured. The docs page, the Analyzers README, the attributes' XML docs and the design note state what each attribute accepts and what proves it. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- docs/type-constraints.md | 19 +++-- src/CodoMetis.TypeKit.Analyzers/README.md | 15 ++-- .../TypeArgumentConstraintAnalyzer.cs | 46 +++++++++-- .../TypeArgumentConstraints.cs | 30 ++++--- .../TypeArgumentConstraintAnalyzerTests.cs | 82 +++++++++++++++++++ 6 files changed, 161 insertions(+), 33 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index c2fbc4a..2990e47 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -40,7 +40,7 @@ exist. | CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | -| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | +| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | | CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | diff --git a/docs/type-constraints.md b/docs/type-constraints.md index 9219657..c5b048f 100644 --- a/docs/type-constraints.md +++ b/docs/type-constraints.md @@ -14,7 +14,7 @@ examples import `CodoMetis.TypeKit`. | `[Abstract]` | an abstract class or record | a concrete type, and an interface | | `[Sealed]` | a sealed class or record, a struct, an enum | a type that can be derived from | | `[Record]` | a record class or record struct | a class or a struct | -| `[Concrete]` | a class, record or struct that can be instantiated | an abstract or static type, and an interface. No constructor is demanded, unlike `new()` | +| `[Concrete]` | a class, record or struct that can be instantiated, an enum, a delegate | an abstract or static type, an interface, and the abstract bases `Enum`, `Delegate` and `ValueType`. No constructor is demanded, unlike `new()` | An aggregate whose events are a closed family, and whose identifier is a value object: @@ -58,8 +58,8 @@ string hidden = Snapshots.Describe(new Balance()); // CMTK0010: Balance is ## Forwarding -A type parameter given as the argument must carry the attribute itself, or the requirement would end -at the first forwarding parameter: +A type parameter given as the argument must carry the attribute itself, or be constrained as the +table below says, or the requirement would end at the first forwarding parameter: ```csharp public abstract class Leaky : Aggregate; // CMTK0010: TEvent does not carry [Abstract] @@ -73,8 +73,17 @@ A method that implements an interface's method, or overrides a base class's, car of the method it implements whether or not it repeats the attribute, so a call through the class is held to what the interface declared. Repeat the attribute anyway, so the declaration says so. -`struct` proves `[Sealed]` and `[Concrete]`, and `new()` proves `[Concrete]`, so a parameter under -those C# constraints needs no attribute. The open generic inside its own body and an unbound +A parameter whose C# constraints already leave it no argument that breaks the attribute needs no +attribute: + +| Attribute | Proved by | +|---|---| +| `[Abstract]` | nothing but `[Abstract]`: no C# constraint excludes a concrete type | +| `[Sealed]` | `struct` or `unmanaged`, or a bound `where T : U` with `U` proving `[Sealed]`, since a sealed type has no other subtype | +| `[Record]` | a bound that is a record, `where T : AccountEvent`, or a `U` proving `[Record]`, since only records derive from records | +| `[Concrete]` | `new()`, or anything that proves `[Sealed]`, `[Sealed]` itself included, since a sealed type is never abstract | + +The open generic inside its own body and an unbound `typeof(Aggregate<,>)` are never reported. Reflection, `MakeGenericType`, is out of reach, and the attributes carry no run-time check: the guarantee is the build. diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index a6bf62f..2891e31 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -367,13 +367,14 @@ public abstract class Sound<[Abstract] TCommand> : Decider` would never be reported. A generic +| `[Abstract]` | an abstract class or record; not an interface | it carries `[Abstract]`; no C# constraint proves it | +| `[Sealed]` | a sealed class or record, a struct, an enum, a delegate, an array | it carries `[Sealed]`, is constrained to `struct` or `unmanaged`, or is bound (`where T : U`) to a parameter that proves `[Sealed]` | +| `[Record]` | a record class or record struct | it carries `[Record]`, or is bound to a record or to a parameter that proves `[Record]`: only records derive from records | +| `[Concrete]` | a class, record or struct that is not abstract, static or an interface, an enum, a delegate, an array; no constructor is demanded, unlike `new()` | it carries `[Concrete]`, is constrained to `new()`, or proves `[Sealed]`: a sealed type is never abstract | + +A type parameter given as the argument must carry the attribute itself, or be constrained so that no +argument can break it, as the table's last column says; otherwise the requirement would end at the +first forwarding parameter and `Sound` would never be reported. A generic attribute, `[Handles]`, is a use like any other. So is a member of a C# 14 extension block with a constrained type parameter, `extension<[Concrete] T>(T value)`: its argument comes from the receiver, so `handler.Go()`, `handler.Count`, `Handler.Make()`, a method group and an operator are diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index 5d0e41c..1063bbe 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -323,20 +323,52 @@ private static IEnumerable Breaches(TypeKitSymbols symbols, ITypeParamet } } - /// Whether meets ; an array is a sealed, concrete class. + /// + /// Whether meets . An array is a sealed, + /// concrete class. An enum and a delegate are concrete, as they are sealed: an enum is a value type, + /// which new() and struct accept, and a delegate a sealed class made from a target and + /// a method; dynamic is object. Their bases, Enum, Delegate, + /// MulticastDelegate and ValueType, are abstract classes. + /// private static bool Satisfies(ITypeSymbol argument, TypeArgumentConstraint constraint) => constraint switch { TypeArgumentConstraint.Abstract => argument is INamedTypeSymbol { TypeKind: TypeKind.Class, IsAbstract: true, IsStatic: false }, TypeArgumentConstraint.Sealed => argument.TypeKind is TypeKind.Struct or TypeKind.Enum or TypeKind.Array || argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Delegate, IsSealed: true, IsStatic: false }, TypeArgumentConstraint.Record => argument is INamedTypeSymbol { IsRecord: true }, - _ => argument.TypeKind is TypeKind.Array || argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct, IsAbstract: false, IsStatic: false }, + _ => argument.TypeKind is TypeKind.Array or TypeKind.Enum or TypeKind.Delegate or TypeKind.Dynamic + || argument is INamedTypeSymbol { TypeKind: TypeKind.Class or TypeKind.Struct, IsAbstract: false, IsStatic: false }, }; - /// A forwarding type parameter proves a constraint by carrying the attribute, or by a C# constraint that implies it. - private static bool Proves(TypeKitSymbols symbols, ITypeParameterSymbol forwarded, TypeArgumentConstraint constraint) => - Effective(symbols, forwarded, receiver: null).Contains(constraint) - || (constraint is TypeArgumentConstraint.Sealed or TypeArgumentConstraint.Concrete && forwarded.HasValueTypeConstraint) - || (constraint is TypeArgumentConstraint.Concrete && forwarded.HasConstructorConstraint); + /// + /// A forwarding type parameter proves a constraint by carrying the attribute, or by C# constraints + /// that leave it no argument that breaks it: + /// + /// [Sealed]: struct or unmanaged, which admit structs and enums alone; or a + /// bound, where T : U, that proves it, since a sealed type has no other subtype. + /// [Concrete]: new(), or whatever proves [Sealed], since a sealed type is + /// never abstract. + /// [Record]: a bound that is a record, or proves it, since only records derive from + /// records (CS8865). + /// [Abstract]: nothing but the attribute; no C# constraint excludes a concrete type. + /// + /// A cycle of constraints is the compiler's error, cut off by depth. + /// + private static bool Proves(TypeKitSymbols symbols, ITypeParameterSymbol forwarded, TypeArgumentConstraint constraint, int depth = 0) + { + if (Effective(symbols, forwarded, receiver: null).Contains(constraint)) return true; + if (depth >= 8) return false; + + return constraint switch + { + TypeArgumentConstraint.Sealed => forwarded.HasValueTypeConstraint + || forwarded.ConstraintTypes.OfType().Any(bound => Proves(symbols, bound, TypeArgumentConstraint.Sealed, depth + 1)), + TypeArgumentConstraint.Concrete => forwarded.HasConstructorConstraint + || Proves(symbols, forwarded, TypeArgumentConstraint.Sealed, depth + 1), + TypeArgumentConstraint.Record => forwarded.ConstraintTypes.Any(bound => bound is INamedTypeSymbol { IsRecord: true } + || bound is ITypeParameterSymbol parameter && Proves(symbols, parameter, TypeArgumentConstraint.Record, depth + 1)), + _ => false, + }; + } /// /// The constraints a type parameter declares, with those of the parameter it implements or diff --git a/src/CodoMetis.TypeKit/TypeArgumentConstraints.cs b/src/CodoMetis.TypeKit/TypeArgumentConstraints.cs index 3caf5f2..23481ce 100644 --- a/src/CodoMetis.TypeKit/TypeArgumentConstraints.cs +++ b/src/CodoMetis.TypeKit/TypeArgumentConstraints.cs @@ -8,20 +8,22 @@ namespace CodoMetis.TypeKit; /// /// A type parameter given as the argument satisfies it only when it carries [Abstract] /// itself, so the requirement follows the generic chain instead of ending at the first forwarding -/// parameter. An interface is not abstract in this sense. Reflection (MakeGenericType) is -/// out of the analyzer's reach. +/// parameter; no C# constraint excludes a concrete type. An interface is not abstract in this sense. +/// Reflection (MakeGenericType) is out of the analyzer's reach. /// [AttributeUsage(AttributeTargets.GenericParameter)] public sealed class AbstractAttribute : Attribute; /// /// Requires the type argument for this type parameter to be sealed: a sealed class or record, a -/// struct or an enum. C# has no where T : sealed; the CodoMetis.TypeKit analyzer reports a -/// closed use that breaks it (CMTK0010). +/// struct, an enum, a delegate or an array. C# has no where T : sealed; the CodoMetis.TypeKit +/// analyzer reports a closed use that breaks it (CMTK0010). /// /// -/// A type parameter given as the argument satisfies it when it carries [Sealed] itself or is -/// constrained to struct. Reflection is out of the analyzer's reach. +/// A type parameter given as the argument satisfies it when it carries [Sealed] itself, is +/// constrained to struct or unmanaged, or is bound (where T : U) to a type +/// parameter that satisfies it, since a sealed type has no other subtype. Reflection is out of the +/// analyzer's reach. /// [AttributeUsage(AttributeTargets.GenericParameter)] public sealed class SealedAttribute : Attribute; @@ -32,21 +34,23 @@ public sealed class SealedAttribute : Attribute; /// (CMTK0010). /// /// -/// A type parameter given as the argument satisfies it only when it carries [Record] itself. -/// Reflection is out of the analyzer's reach. +/// A type parameter given as the argument satisfies it when it carries [Record] itself, or is +/// bound to a record (where T : AccountEvent) or to a type parameter that satisfies it, since +/// only records derive from records. Reflection is out of the analyzer's reach. /// [AttributeUsage(AttributeTargets.GenericParameter)] public sealed class RecordAttribute : Attribute; /// /// Requires the type argument for this type parameter to be concrete: a class, record or struct that -/// is not abstract, not static and not an interface. new() says that too, and demands a public -/// parameterless constructor with it; this does not. The CodoMetis.TypeKit analyzer reports a closed -/// use that breaks it (CMTK0010). +/// is not abstract, not static and not an interface, an enum, a delegate or an array. new() +/// says that too, and demands a public parameterless constructor with it; this does not. The +/// CodoMetis.TypeKit analyzer reports a closed use that breaks it (CMTK0010). /// /// -/// A type parameter given as the argument satisfies it when it carries [Concrete] itself or -/// is constrained to struct or new(). Reflection is out of the analyzer's reach. +/// A type parameter given as the argument satisfies it when it carries [Concrete] itself, is +/// constrained to new(), or satisfies , since a sealed type is +/// never abstract. Reflection is out of the analyzer's reach. /// [AttributeUsage(AttributeTargets.GenericParameter)] public sealed class ConcreteAttribute : Attribute; diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs index 02a5c61..de0adb0 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -190,6 +190,88 @@ public static class Proofs Cmtk0010(0, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// [Concrete] is a type that is not abstract, static or an interface, the type a host can + /// instantiate. An enum is a value type, which new() and struct both accept, and a + /// struct parameter that proves [Concrete] can be one; a delegate is a sealed class, + /// made from a target and a method, as a class with only a parameterised constructor is made from + /// its arguments; dynamic is object. Their abstract bases are not. + /// + [Fact] + public Task Concrete_accepts_an_enum_a_delegate_and_dynamic_and_refuses_their_abstract_bases() => + Test( + """ + public delegate void Handled(Handler handler); + + public static class Instantiable + { + public static Factory Enum; + public static Factory Delegate; + public static Factory> FrameworkDelegate; + public static Factory Dynamic; + public static Factory Object; + public static Factory NullableStruct; + public static Factory<(int, string)> Tuple; + public static Factory<{|#0:System.Enum|}> EnumBase; + public static Factory<{|#1:System.Delegate|}> DelegateBase; + public static Factory<{|#2:MulticastDelegate|}> MulticastBase; + public static Factory<{|#3:ValueType|}> ValueTypeBase; + } + """, + Cmtk0010(0, "Enum", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(1, "Delegate", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(2, "MulticastDelegate", "T", "Factory", "is not a concrete class, record or struct"), + Cmtk0010(3, "ValueType", "T", "Factory", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A forwarding parameter whose own constraints already prove what the attribute asks needs no + /// attribute. Only records derive from records (CS8865), so a parameter bound to a record, or to a + /// parameter that proves [Record], is a record. A sealed type has no other subtype, so a + /// parameter bound to one that proves [Sealed] is that type. A sealed type is never abstract, + /// so whatever proves [Sealed] proves [Concrete], unmanaged included. Nothing + /// but the attribute proves [Abstract]: C# has no constraint that excludes a concrete type. + /// + [Fact] + public Task A_constraint_that_already_proves_the_attribute_is_a_proof() => + Test( + """ + public sealed class Journal<[Record] T> { } + + public static class Proven + { + public static Journal BoundToARecord() where T : Command => new(); + public static Journal BoundThroughAParameter() where T : U where U : Command => new(); + public static Journal BoundToARecordParameter() where T : U => new(); + public static Box BoundToASealedParameter() where T : U => new(); + public static Factory SealedIsConcrete<[Sealed] T>() => new(); + public static Factory BoundToASealedParameterIsConcrete() where T : U => new(); + public static Box Unmanaged() where T : unmanaged => new(); + public static Factory UnmanagedIsConcrete() where T : unmanaged => new(); + } + + public static class Unproven + { + public static Journal<{|#0:T|}> BoundToAClass() where T : Handler => new(); + public static Decider<{|#1:T|}, PlaceOrder> BoundToAnAbstractRecord() where T : Command => null!; + public static Decider<{|#2:T|}, PlaceOrder> BoundToAnAbstractParameter() where T : U => null!; + public static Factory<{|#3:T|}> BoundToAConcreteParameter() where T : U => new(); + public static Factory<{|#4:T|}> BoundToEnum() where T : System.Enum => new(); + public static Factory<{|#5:T|}> RecordIsNotConcrete<[Record] T>() => new(); + public static Box<{|#6:T|}> ConcreteIsNotSealed<[Concrete] T>() => new(); + public static Journal<{|#7:T|}> AStructIsNoRecord() where T : struct => new(); + } + """, + Cmtk0010(0, "T", "T", "Journal", "is a type parameter that does not carry [Record]"), + Cmtk0010(1, "T", "TCommand", "Decider", "is a type parameter that does not carry [Abstract]"), + Cmtk0010(2, "T", "TCommand", "Decider", "is a type parameter that does not carry [Abstract]"), + Cmtk0010(3, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]"), + Cmtk0010(4, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]"), + Cmtk0010(5, "T", "T", "Factory", "is a type parameter that does not carry [Concrete]"), + Cmtk0010(6, "T", "T", "Box", "is a type parameter that does not carry [Sealed]"), + Cmtk0010(7, "T", "T", "Journal", "is a type parameter that does not carry [Record]")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task Each_constraint_accepts_and_refuses_what_it_says() => Test( From aaae5b3d24d43831dddcda5b60955d83c5148e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:54:44 +0200 Subject: [PATCH 132/147] fix: the CMTK0011 code fix keeps the source as written, and is withheld on a partial type with a primary constructor The fix laid the whole type out again through the formatter and built its tokens with elastic trivia. Measured: the members' own spacing was rewritten and tabs became spaces; a constructor without modifiers got its doc comment a second time after its attribute and its space moved ([Obsolete] internal X()); a comment after a replaced modifier was dropped; in a CRLF file the added lines, and the brace's own, ended in LF. It now writes its own trivia and asks no formatter. The accessibility takes the place of the first keyword written, keeping what led it and what followed the last, or goes before the name after the attributes. The constructor is added as the first member, indented as the members are (a tab where they use tabs, one level inside the braces of an empty body), with the line ending of the brace it follows; a type on one line gets it on that line, and one ending in ';' a body there, the ';''s comment kept after it. On a partial type whose other part has a primary constructor, the added constructor did not chain to it (CS8862); the fix is withheld there, as it was for a primary constructor in the same part. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- docs/type-constraints.md | 2 +- ...ParameterlessConstructorCodeFixProvider.cs | 188 ++++++++++-- src/CodoMetis.TypeKit.Analyzers/README.md | 9 +- ...edParameterlessConstructorAnalyzerTests.cs | 268 ++++++++++++++++-- 5 files changed, 417 insertions(+), 52 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index 2990e47..8bdcdcb 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -41,7 +41,7 @@ exist. | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | | CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | -| CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor gets none | Error | built 2026-10-13 for 2.0.0 (phase 13) | +| CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor in any of its parts gets none (CS8862). It writes its own trivia and asks no formatter, which laid out the whole type again and wrote the machine's line endings: doc comments, attributes, comments and the members' layout stay, added lines take the members' indentation and the brace's line ending, a one-line type gets the constructor on its line | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | diff --git a/docs/type-constraints.md b/docs/type-constraints.md index c5b048f..8eaf1c9 100644 --- a/docs/type-constraints.md +++ b/docs/type-constraints.md @@ -127,7 +127,7 @@ public sealed class Importer : Plugin; // CMTK0011: the impl | declares no constructor, and another accessibility is required | at the type | adds `internal Importer() { }` | | declares constructors, none parameterless | at the type | adds one | | has a parameterless constructor of the wrong accessibility | at the constructor | changes it | -| has a primary constructor | at the type | none: every other constructor would chain to the primary one, with arguments only its author knows | +| has a primary constructor, in any of its parts | at the type | none: every other constructor would chain to the primary one, with arguments only its author knows | | is abstract, or an interface | no: only concrete descendants are checked | | | is a struct or a record struct | no: its parameterless constructor is public and can be nothing else (CS8958), so it satisfies `Public` as it is and no other accessibility can be asked of it | | diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs index db46958..ea1ea8e 100644 --- a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Collections.Immutable; using System.Composition; using System.Linq; @@ -8,7 +9,7 @@ using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Text; namespace CodoMetis.TypeKit.Analyzers; @@ -17,9 +18,17 @@ namespace CodoMetis.TypeKit.Analyzers; /// accessibility of the one it declares. /// /// -/// A type with a primary constructor gets no fix: every other constructor of such a type chains to -/// the primary one, and the arguments to chain with are the author's to choose. Nor does a struct, -/// which the rule never reports: its parameterless constructor can only be public. +/// +/// A type with a primary constructor, in any of its parts, gets no fix: every other constructor of +/// such a type chains to the primary one (CS8862), and the arguments to chain with are the author's to +/// choose. Nor does a struct, which the rule never reports: its parameterless constructor can only be +/// public. +/// +/// +/// The fix writes its own trivia and asks no formatter, which would lay out the whole type again: the +/// type's and the constructor's doc comments, attributes and comments stay where they are, the members +/// keep their layout, and the lines it adds are indented as the members are and end as the file's do. +/// /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RequiredParameterlessConstructorCodeFixProvider))] [Shared] @@ -66,47 +75,164 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) equivalenceKey: $"{DiagnosticId}:change"), diagnostic); } - else if (node.FirstAncestorOrSelf() is { ParameterList: null } type) + else if (node.FirstAncestorOrSelf() is { } type + && !await HasPrimaryConstructorAsync(context.Document, type, context.CancellationToken).ConfigureAwait(false)) { context.RegisterCodeFix( CodeAction.Create( $"Add {(required == Accessibility.Internal ? "an" : "a")} {spelling} parameterless constructor", - cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(type, WithConstructor(type, required)))), + cancellationToken => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(type, WithConstructor(type, required, root.SyntaxTree.GetText(cancellationToken))))), equivalenceKey: $"{DiagnosticId}:add"), diagnostic); } } } - /// Only the modifiers change; the rest of the declaration keeps its own layout. + /// + /// Whether any part of the type declares a primary constructor, or whether that cannot be told: a + /// fix withheld is better than one that does not compile. + /// + private static async Task HasPrimaryConstructorAsync(Document document, TypeDeclarationSyntax type, CancellationToken cancellationToken) + { + if (type.ParameterList is not null) return true; + if (!type.Modifiers.Any(SyntaxKind.PartialKeyword)) return false; + + var model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (model?.GetDeclaredSymbol(type, cancellationToken) is not INamedTypeSymbol symbol) return true; + + return symbol.DeclaringSyntaxReferences.Any(reference => reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax { ParameterList: not null }); + } + + /// + /// Only the accessibility changes. It takes the place of the first accessibility keyword written, + /// with what led it and what followed the last, comments included; where none is written, it goes + /// before the other modifiers, or before the name, after the attributes, taking what led there. + /// private static ConstructorDeclarationSyntax WithAccessibility(ConstructorDeclarationSyntax constructor, Accessibility required) { - var leading = constructor.Modifiers.Count > 0 ? constructor.Modifiers[0].LeadingTrivia : constructor.GetLeadingTrivia(); - var kept = constructor.Modifiers.Where(modifier => !IsAccessibility(modifier)).Select(modifier => modifier.WithLeadingTrivia()); - var replaced = Modifiers(required).Select(token => token.WithTrailingTrivia(SyntaxFactory.Space)).Concat(kept).ToArray(); + var modifiers = constructor.Modifiers; + var written = modifiers.Where(IsAccessibility).ToList(); + + if (written.Count == 0) + { + if (modifiers.Count == 0) + { + return constructor.WithModifiers(SyntaxFactory.TokenList(Keywords(required, constructor.Identifier.LeadingTrivia, SyntaxFactory.TriviaList(SyntaxFactory.Space)))) + .WithIdentifier(constructor.Identifier.WithLeadingTrivia()); + } + + var leading = Keywords(required, modifiers[0].LeadingTrivia, SyntaxFactory.TriviaList(SyntaxFactory.Space)); + return constructor.WithModifiers(SyntaxFactory.TokenList(leading.Concat(modifiers.Select((modifier, index) => index == 0 ? modifier.WithLeadingTrivia() : modifier)))); + } - replaced[0] = replaced[0].WithLeadingTrivia(leading); + // protected /* note */ internal: a comment after a keyword that goes is kept after the new ones. + var last = written[written.Count - 1]; + var trailing = SyntaxFactory.TriviaList(written.Take(written.Count - 1) + .SelectMany(keyword => keyword.TrailingTrivia.Any(IsComment) ? keyword.TrailingTrivia : SyntaxFactory.TriviaList()) + .Concat(last.TrailingTrivia)); + var tokens = new List(); - return constructor.Modifiers.Count > 0 - ? constructor.WithModifiers(SyntaxFactory.TokenList(replaced)) - : constructor.WithModifiers(SyntaxFactory.TokenList(replaced)).WithIdentifier(constructor.Identifier.WithLeadingTrivia()); + foreach (var modifier in modifiers) + { + if (modifier == written[0]) tokens.AddRange(Keywords(required, modifier.LeadingTrivia, trailing)); + else if (!IsAccessibility(modifier)) tokens.Add(modifier); + } + + return constructor.WithModifiers(SyntaxFactory.TokenList(tokens)); } - private static TypeDeclarationSyntax WithConstructor(TypeDeclarationSyntax type, Accessibility required) + /// + /// The type with the constructor as its first member, laid out as the body it joins: on a body of + /// several lines, on lines of its own, indented as the members are; on a body of one line, on that + /// line; on a declaration ending in ;, in a body on that line, after which the ;'s + /// trivia, a comment included, is kept. + /// + private static TypeDeclarationSyntax WithConstructor(TypeDeclarationSyntax type, Accessibility required, SourceText text) { - var constructor = SyntaxFactory.ConstructorDeclaration(type.Identifier.WithoutTrivia()) - .WithModifiers(SyntaxFactory.TokenList(Modifiers(required))) - .WithBody(SyntaxFactory.Block()) - .WithAdditionalAnnotations(Formatter.Annotation); + var signature = $"{Spell(required)} {type.Identifier.Text}()"; + + if (type.OpenBraceToken.IsKind(SyntaxKind.None) || type.OpenBraceToken.IsMissing) + { + var semicolon = type.SemicolonToken; + var spaced = semicolon.GetPreviousToken().TrailingTrivia.LastOrDefault().IsKind(SyntaxKind.WhitespaceTrivia); + var open = SyntaxFactory.Token(spaced ? semicolon.LeadingTrivia : semicolon.LeadingTrivia.Add(SyntaxFactory.Space), SyntaxKind.OpenBraceToken, SyntaxFactory.TriviaList(SyntaxFactory.Space)); + var close = SyntaxFactory.Token(SyntaxFactory.TriviaList(), SyntaxKind.CloseBraceToken, semicolon.TrailingTrivia); + + return type.WithSemicolonToken(default) + .WithOpenBraceToken(open) + .WithMembers(SyntaxFactory.SingletonList(Constructor($"{signature} {{ }}", SyntaxFactory.TriviaList(), SyntaxFactory.TriviaList(SyntaxFactory.Space)))) + .WithCloseBraceToken(close); + } - // A declaration ending in ';' (a record with no body) gets braces to hold the constructor. - var withBody = type.SemicolonToken.IsKind(SyntaxKind.SemicolonToken) - ? type.WithSemicolonToken(default).WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)).WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) - : type; + var end = type.OpenBraceToken.TrailingTrivia.LastOrDefault(trivia => trivia.IsKind(SyntaxKind.EndOfLineTrivia)); + if (!end.IsKind(SyntaxKind.EndOfLineTrivia)) + { + var lead = type.OpenBraceToken.TrailingTrivia.Count == 0 ? SyntaxFactory.TriviaList(SyntaxFactory.Space) : SyntaxFactory.TriviaList(); + return type.WithMembers(type.Members.Insert(0, Constructor($"{signature} {{ }}", lead, SyntaxFactory.TriviaList(SyntaxFactory.Space)))); + } - return withBody.WithMembers(withBody.Members.Insert(0, constructor)).WithAdditionalAnnotations(Formatter.Annotation); + // The line ending of the brace the constructor follows, so a CRLF file stays CRLF. + var eol = end.ToString(); + var indent = MemberIndentation(type, text); + var leading = indent.Length == 0 ? SyntaxFactory.TriviaList() : SyntaxFactory.TriviaList(SyntaxFactory.Whitespace(indent)); + var trailing = type.Members.Count > 0 + ? SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine(eol), SyntaxFactory.EndOfLine(eol)) + : SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine(eol)); + + return type.WithMembers(type.Members.Insert(0, Constructor($"{signature}{eol}{indent}{{{eol}{indent}}}", leading, trailing))); + } + + /// + /// A constructor parsed from its text, so every token carries the trivia written there, and none + /// is elastic trivia a code action's cleanup would lay out again; the trivia around it is given. + /// + private static MemberDeclarationSyntax Constructor(string source, SyntaxTriviaList leading, SyntaxTriviaList trailing) => + (SyntaxFactory.ParseMemberDeclaration(source) ?? throw new System.InvalidOperationException($"'{source}' does not parse as a constructor.")) + .WithLeadingTrivia(leading) + .WithTrailingTrivia(trailing); + + /// + /// The indentation of the first member's line; without a member, the closing brace's, or the + /// type's, one level deeper, a tab where the file indents with tabs. + /// + private static string MemberIndentation(TypeDeclarationSyntax type, SourceText text) + { + if (type.Members.Count > 0 && Indentation(text, type.Members[0].SpanStart) is { } member) return member; + + var outer = Indentation(text, type.CloseBraceToken.SpanStart) ?? Indentation(text, type.SpanStart) ?? ""; + var tabs = outer.IndexOf('\t') >= 0 || text.Lines.Any(line => line.Span.Length > 0 && text[line.Start] == '\t'); + + return outer + (tabs ? "\t" : " "); } + /// The whitespace that starts the line of , or when anything else stands before it. + private static string? Indentation(SourceText text, int position) + { + var line = text.Lines.GetLineFromPosition(position); + var end = line.Start; + + while (end < position && text[end] is ' ' or '\t') end++; + + return end == position ? text.ToString(TextSpan.FromBounds(line.Start, end)) : null; + } + + /// The accessibility's keywords, the first led by , the last followed by . + private static IEnumerable Keywords(Accessibility accessibility, SyntaxTriviaList leading, SyntaxTriviaList trailing) + { + var kinds = Modifiers(accessibility); + + for (var i = 0; i < kinds.Length; i++) + { + yield return SyntaxFactory.Token( + i == 0 ? leading : SyntaxFactory.TriviaList(), + kinds[i], + i == kinds.Length - 1 ? trailing : SyntaxFactory.TriviaList(SyntaxFactory.Space)); + } + } + + private static bool IsComment(SyntaxTrivia trivia) => + trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia); + private static string Spell(Accessibility accessibility) => accessibility switch { Accessibility.Public => "public", @@ -121,13 +247,13 @@ private static bool IsAccessibility(SyntaxToken token) => token.IsKind(SyntaxKind.PublicKeyword) || token.IsKind(SyntaxKind.InternalKeyword) || token.IsKind(SyntaxKind.ProtectedKeyword) || token.IsKind(SyntaxKind.PrivateKeyword); - private static SyntaxToken[] Modifiers(Accessibility accessibility) => accessibility switch + private static SyntaxKind[] Modifiers(Accessibility accessibility) => accessibility switch { - Accessibility.Public => [SyntaxFactory.Token(SyntaxKind.PublicKeyword)], - Accessibility.Internal => [SyntaxFactory.Token(SyntaxKind.InternalKeyword)], - Accessibility.Protected => [SyntaxFactory.Token(SyntaxKind.ProtectedKeyword)], - Accessibility.ProtectedOrInternal => [SyntaxFactory.Token(SyntaxKind.ProtectedKeyword), SyntaxFactory.Token(SyntaxKind.InternalKeyword)], - Accessibility.ProtectedAndInternal => [SyntaxFactory.Token(SyntaxKind.PrivateKeyword), SyntaxFactory.Token(SyntaxKind.ProtectedKeyword)], - _ => [SyntaxFactory.Token(SyntaxKind.PrivateKeyword)], + Accessibility.Public => [SyntaxKind.PublicKeyword], + Accessibility.Internal => [SyntaxKind.InternalKeyword], + Accessibility.Protected => [SyntaxKind.ProtectedKeyword], + Accessibility.ProtectedOrInternal => [SyntaxKind.ProtectedKeyword, SyntaxKind.InternalKeyword], + Accessibility.ProtectedAndInternal => [SyntaxKind.PrivateKeyword, SyntaxKind.ProtectedKeyword], + _ => [SyntaxKind.PrivateKeyword], }; } diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 2891e31..b95b012 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -413,9 +413,12 @@ public sealed class Identity : Module; // CMTK0011: the im A class that declares no constructor has a public parameterless one and satisfies `Public` as it is. A wrong accessibility is reported at the constructor, and the fix changes it there; a missing -one is reported at the type, and the fix adds it. A record or class with a primary constructor gets -no fix: every other constructor of such a type chains to the primary one, with arguments only its -author knows. The requirement reaches a type through any chain of base classes and interfaces, and +one is reported at the type, and the fix adds it. A record or class with a primary constructor, in +any of its parts, gets no fix: every other constructor of such a type chains to the primary one, +with arguments only its author knows. The fix touches nothing but the constructor: doc comments, +attributes, comments and the members' layout stay as written, the lines it adds are indented as the +members are and end as the file's lines do, and a type written on one line gets the constructor on +that line. The requirement reaches a type through any chain of base classes and interfaces, and the attribute is read from metadata, so the marked type can live in another package. A struct or record struct is never reported. It always has a public parameterless constructor, the diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs index 0d80ddb..e31b4d9 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs @@ -324,14 +324,7 @@ internal Identity() { } public Task The_fix_writes_the_non_public_constructor_a_class_only_has_implicitly() => new CodeFixTest( Subjects + "public sealed class {|#0:Identity|} : Subjects.Module { }", - Subjects + """ - public sealed class Identity : Subjects.Module - { - internal Identity() - { - } - } - """) + Subjects + "public sealed class Identity : Subjects.Module { internal Identity() { } }") { ExpectedDiagnostics = { Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal") }, } @@ -396,14 +389,7 @@ public class Orders : Subjects.{{marked}} public Task The_fix_s_title_says_an_internal() => new CodeFixTest( Subjects + "public sealed class {|#0:Identity|} : Subjects.Module { }", - Subjects + """ - public sealed class Identity : Subjects.Module - { - internal Identity() - { - } - } - """) + Subjects + "public sealed class Identity : Subjects.Module { internal Identity() { } }") { ExpectedDiagnostics = { Cmtk0011("Identity", "has the implicit public parameterless constructor", "Module", "internal") }, CodeActionVerifier = (action, verifier) => verifier.Equal("Add an internal parameterless constructor", action.Title), @@ -486,4 +472,254 @@ public Task A_type_with_a_primary_constructor_gets_no_fix() => FixedState = { ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") } }, } .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A primary constructor in another part of a partial type binds every constructor of the type, the + /// one the fix would add included (CS8862), so the fix is withheld there as it is in the same part. + /// The rule reports at the first part, which has no parameter list. + /// + [Fact] + public Task A_partial_type_whose_other_part_has_a_primary_constructor_gets_no_fix() + { + const string first = "public sealed partial class {|#0:Orders|} : Subjects.IEndpoint { }"; + const string second = "public sealed partial class Orders(int port) { public int Port => port; }"; + + return new CodeFixTest(Subjects + first, Subjects + first) + { + TestState = { Sources = { ("Second.cs", second) } }, + FixedState = { Sources = { ("Second.cs", second) }, ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") } }, + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + } + + /// + /// The fix adds a constructor and touches nothing else: the type's doc comment, its attributes and a + /// comment beside one stay where they are, and the members keep their layout. + /// + [Fact] + public Task The_fix_keeps_the_type_s_doc_comment_attributes_and_comments() => + new CodeFixTest( + Subjects + """ + /// The orders endpoint. + [System.Serializable] // the host serializes it + [System.Diagnostics.DebuggerDisplay("Orders")] + public sealed class {|#0:Orders|} : Subjects.IEndpoint // mapped by the host + { + // the port the host gives + public Orders(int port) { } + + public int Port { get; } + } + """, + Subjects + """ + /// The orders endpoint. + [System.Serializable] // the host serializes it + [System.Diagnostics.DebuggerDisplay("Orders")] + public sealed class Orders : Subjects.IEndpoint // mapped by the host + { + public Orders() + { + } + + // the port the host gives + public Orders(int port) { } + + public int Port { get; } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A constructor without modifiers keeps its doc comment, its attribute and a comment beside it where + /// they are: the accessibility goes before the name, after the attributes, and so does a comment + /// after a modifier that is replaced. + /// + [Fact] + public Task The_fix_keeps_the_constructor_s_doc_comment_attributes_and_comments() => + new CodeFixTest( + Subjects + """ + public class Identity : Subjects.Module + { + /// For the host. + [System.Obsolete("For the host")] // not for code + {|#0:Identity|}() { } + } + + public class Inline : Subjects.Module + { + [System.Obsolete("For the host")] {|#1:Inline|}() { } + } + + public class Commented : Subjects.Module + { + /// For the host. + protected /* the host's */ {|#2:Commented|}() { } + } + """, + Subjects + """ + public class Identity : Subjects.Module + { + /// For the host. + [System.Obsolete("For the host")] // not for code + internal Identity() { } + } + + public class Inline : Subjects.Module + { + [System.Obsolete("For the host")] internal Inline() { } + } + + public class Commented : Subjects.Module + { + /// For the host. + internal /* the host's */ Commented() { } + } + """) + { + ExpectedDiagnostics = + { + Cmtk0011("Identity", "declares a private parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("Inline", "declares a private parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(2).WithArguments("Commented", "declares a protected parameterless constructor", "Module", "internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// The added constructor is indented as the members around it are, with tabs where they use tabs. + [Fact] + public Task The_fix_indents_as_the_members_do() => + new CodeFixTest( + Subjects + "public class {|#0:Orders|} : Subjects.IEndpoint\n{\n\tpublic int Port;\n\n\tpublic Orders(int port) { Port = port; }\n}\n", + Subjects + "public class Orders : Subjects.IEndpoint\n{\n\tpublic Orders()\n\t{\n\t}\n\n\tpublic int Port;\n\n\tpublic Orders(int port) { Port = port; }\n}\n") + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A body with no member is indented one level inside its braces, a nested and generic type's + /// included; the constructor is named as the type is written, a keyword escaped. + /// + [Fact] + public Task The_fix_indents_an_empty_body_one_level_inside_its_braces() => + new CodeFixTest( + Subjects + """ + public class Outer + { + public sealed class {|#0:Inner|} : Subjects.Module + { + } + } + + public sealed class {|#1:@event|} : Subjects.Module + { + } + """, + Subjects + """ + public class Outer + { + public sealed class Inner : Subjects.Module + { + internal Inner() + { + } + } + } + + public sealed class @event : Subjects.Module + { + internal @event() + { + } + } + """) + { + ExpectedDiagnostics = + { + Cmtk0011("Inner", "has the implicit public parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("event", "has the implicit public parameterless constructor", "Module", "internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A file with CRLF line endings stays CRLF throughout: the lines the fix adds end as the file's + /// do, whatever the machine running the fix uses. + /// + [Fact] + public Task The_fix_keeps_the_file_s_line_endings() => + new CodeFixTest( + (Subjects + """ + public sealed class {|#0:Orders|} : Subjects.IEndpoint + { + public Orders(int port) { } + } + + public class Identity : Subjects.Module + { + public {|#1:Identity|}() { } + } + + """).ReplaceLineEndings("\r\n"), + (Subjects + """ + public sealed class Orders : Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) { } + } + + public class Identity : Subjects.Module + { + internal Identity() { } + } + + """).ReplaceLineEndings("\r\n")) + { + // One add and one change, each a fix-all pass of its own. + NumberOfFixAllIterations = 2, + ExpectedDiagnostics = + { + Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("Identity", "declares a public parameterless constructor", "Module", "internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A type written on one line gets the constructor on that line, and one ending in ; gets a + /// body there to hold it, the comment after the ; kept after the body. + /// + [Fact] + public Task The_fix_keeps_a_type_on_one_line_on_its_line() => + new CodeFixTest( + Subjects + """ + public sealed class {|#0:Importer|} : Subjects.Module; // found by scanning + + public sealed record {|#1:Activated|} : Subjects.IActivated; + + public class {|#2:Gauge|} : Subjects.Widget { public Gauge(int size) { } } + """, + Subjects + """ + public sealed class Importer : Subjects.Module { internal Importer() { } } // found by scanning + + public sealed record Activated : Subjects.IActivated { private Activated() { } } + + public class Gauge : Subjects.Widget { protected internal Gauge() { } public Gauge(int size) { } } + """) + { + ExpectedDiagnostics = + { + Cmtk0011("Importer", "has the implicit public parameterless constructor", "Module", "internal"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(1).WithArguments("Activated", "has the implicit public parameterless constructor", "IActivated", "private"), + new DiagnosticResult("CMTK0011", DiagnosticSeverity.Error).WithLocation(2).WithArguments("Gauge", "declares no parameterless constructor", "Widget", "protected internal"), + }, + } + .RunAsync(TestContext.Current.CancellationToken); } From 28924be99460cd9a73ea41b82a3a3ca01573c238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:58:08 +0200 Subject: [PATCH 133/147] docs: the 2.0.0 changelog section, which is the release notes, says each change in one line The detail lives in docs/migrating-to-2.md and the topic pages; the section links the guide absolutely, since the release page resolves no relative link. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 277 ++++++--------------------------------------------- 1 file changed, 31 insertions(+), 246 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb57b9..dbfb4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,261 +12,46 @@ section is the GitHub release's notes. ## 2.0.0 — Unreleased -A major version: it targets `net10.0` and Metalama 2026.1 as 1.x does, and the breaks are the ones -Semantic Versioning reserves for a major, listed under Changed. Package validation against 1.5.1 -finds one difference in the public surface, `FaultProblem`'s constraint, which still accepts -every type argument it accepted; the others are behaviour no compatibility check reads: CMTK0003 at -Error, `FirstOrNone` on a query, CMTK1022 and the new CMTK1005 refusals, `TryParse` letting an -exception from `Create` through, and the EF Core and OpenAPI refusals below. The new -analyzer Errors, CMTK0010 to CMTK0012, report only on what this release adds, so nothing that built -against 1.5.1 meets them. +Still `net10.0` and Metalama 2026.1. What builds or runs differently, and what to write instead: +[Migrating from 1.5 to 2.0](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/migrating-to-2.md). + +### Breaking + +- CMTK0003, a dropped `Result` or `Option`, is an Error, and reports more forms (`Wait()`, loops that never read, `??`, tuples). +- `FirstOrNone`/`FirstOrError` on an `IQueryable` run in the query; `LastOrNone`/`LastOrError` on a query no longer compile. +- CMTK1022 refuses an abstract value object; CMTK1005 refuses one over `object` or `System.Enum`, or named as a generated member. +- A generated `TryParse` lets an exception from `Create` through instead of answering `false`. +- EF Core refuses `.Value` on a column with the application's own converter, and ordering by a hand-written `CompareTo`, in queries and query filters. +- EF Core: value-object keys over `sbyte`, `ushort`, `uint` and `ulong` are generated as EF generates the plain type, so a migration may alter the column. +- OpenAPI: a component name claimed by two types throws. +- Async combinators throw `ArgumentNullException` for a delegate that returns no task. +- `FaultProblem` takes any `notnull` fault type. ### Added -- **`CodoMetis.TypeKit`**: `Result.Catch`, which turns an exception into a result. - - `Catch(() => …)` runs the call and answers `Result`, or `Result` for an - action. `Catch` catches that type only and lets any other propagate. - - `CatchAsync` awaits the task, so an exception thrown after its first `await` is the error too. - - An `OperationCanceledException` passes through every overload, and so does an - `AggregateException` that holds only cancellations, as `Wait()` and `.Result` throw for a - cancelled task; one that holds anything beside them is the error, as thrown. A timeout, one over a - `TimeoutException` as `HttpClient` throws when its `Timeout` elapses, is the error, wrapped or - not, `.Result` on a timed-out call included. - - `CatchAsync`'s error is what `await` throws: for a task that faulted with several exceptions, - such as `Task.WhenAll`'s, the first of them, while the task's `Exception` holds them all. - - A delegate that returns a `Task`, an `async` lambda among them, given to `Catch` or to its command - form `Catch` is a compile error naming `CatchAsync`. So is a delegate that returns any - value given to the command form, which would discard it. - - A `ValueTask`, or a task spelled as the type argument, is refused before the delegate runs. - - A delegate that returns null throws as `Result.Success(null)` does, and so does one given to - `CatchAsync` that returns no task. -- **The closed-set family**, the release's headline: `IClosedSet` in the `CodoMetis.TypeKit` package, generated - by `CodoMetis.TypeKit.Generators`. A sealed partial class whose every instance is a `public static - readonly` field, with constant data through a private constructor where the members carry some. The - generators add `Name`, `QualifiedName`, `All` in declaration order and read-only, `Match` with one required delegate - per member, `TryParse` and `Parse` by name, each with an `ignoreCase` overload for a header or a - query string as a client typed it while JSON, the column, binding and `IParsable` stay ordinal, - `ToString()`, a JSON converter that writes the name and - reads the member itself back, a private constructor where none is declared, and the attribute the - satellites read. CMTK1013 to CMTK1019 refuse a declaration that is not partial, not sealed, without - members, with a constructor that is not private, with another public static member of its own type, - generic, or with a member not initialized with `new` where it is declared; CMTK1020 refuses a - record. A hand-written `ToString()` is kept, and an overload of a generated method with another - signature; any other generated member written by hand is CMTK1011. An instance the set's - own code makes beside its members has no name, and reading it throws rather than writing a null. - `All` and the name lookups throw while the set is still initialising, so a static field - initializer of the set itself never reads a half-built one. - CMTK0002 covers a closed set without the generators. - CMTK1021 refuses a member named as a generated member, or two whose names differ only in case - (`Open` and `open`, `Express` and `EXPRESS`), which collide in `TryParse(name, ignoreCase: true)`, and - in `Match`'s parameters where the first letter is the difference. `Match` checks every delegate for null before it picks one, - and a set is a JSON dictionary key by its name. A value object can wrap a closed set, from its own - project too, and is generated in full: `ClosedSetAspect` runs first, so the value object's - `Parse`, `TryParse`, `IParsable` and type converter see what the set's aspect adds. - `CodoMetis.TypeKit.EntityFrameworkCore` maps a closed set to a text column of the member's name, in a - primitive collection and a JSON column too, through a compiled model and under Native AOT - (`ClosedSetConverter` names it explicitly); `CodoMetis.TypeKit.AspNetCore` describes it as a - string enum. Nothing is registered per set. -- **`CodoMetis.TypeKit.Analyzers`**: CMTK0012, a closed set's `Match` given an argument by position. - Every parameter has the same type, one delegate per member in declaration order, so reordering two - members swapped their branches at such a call and compiled. A conditional call is reported too, - `status?.Match(…)?.ToUpper()` among them. The code fix names every argument after the parameter it - binds to. -- **`CodoMetis.TypeKit`**: `Patch`, a field of a JSON Merge Patch (RFC 7396): absent, null or a - value, where System.Text.Json alone cannot tell an absent property from a null one. `default` is - absent, the one meaningful default of the package, and the analyzers leave it alone. Reading never - refuses a null; a value object inside a patch keeps its own rule. `Match`, `TryGetValue`, `Tap`, - `IfPresent` and `ToOption` apply it without reflection, `Tap` and `IfPresent` returning the patch; `IfPresent` hands a clear over as null, a - `T?` over a struct too, never as `default(T)`. `Map` changes a value and keeps a clear and an - absence, and `Traverse` validates a value with a `Create`'s own fault, - `request.Email.Traverse(Email.Create)` as a `Result, EmailFault>`, a clear and an - absence passing through; a `Map` selector that answers null is refused, since a clear is the Null - state and never a value. A value the patch cannot read is refused at the property's path - (`$.email`, `$[1].quantity`), with the nested refusal's own message. Writing an absent patch is refused, and - `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out. Under a source-generated context a - primitive, a string, a `Guid`, a date, a `Uri` and a value object need no listing; a class or an - enum does. Works under Native AOT: the converter is registered by the closed type's static - constructor, since a converter built by reflection has no code there. -- **`CodoMetis.TypeKit.AspNetCore`**: `AddTypeKit()` describes a `Patch` property as `T`'s schema, - inlined, not required and admitting null, in 3.1 and 3.0; a value object inside a patch through the - type it wraps. Null joins what restricts the values, so a validator accepts it beside every value - the argument accepts: an enum's list, the type, or a branch beside the argument's own; an `object`'s - schema stays unrestricted. -- **`CodoMetis.TypeKit.Analyzers`**: CMTK0010, the constraints a type parameter cannot declare. - `[Abstract]`, `[Sealed]`, `[Record]` and `[Concrete]` (new in the base package) on a type parameter - say what C# has no `where` for, and the rule reports a type argument that breaks it at any closed - use: a base type, a member, a parameter, a local, a `typeof`, a call or a method group with its - arguments written or inferred, a C# 14 extension member, and a generic attribute. A method that - implements an interface's, directly, statically or through a base class, carries its constraint. A type parameter forwarded without the attribute is a breach too, so the requirement - follows the generic chain; `struct` proves `[Sealed]` and `[Concrete]`, `new()` proves `[Concrete]`. - Like every rule, it reports in generated code only where the code maps to a `.razor` or `.cshtml` - file. -- **`CodoMetis.TypeKit.Analyzers`**: CMTK0011, with the package's first code fix. A type marked - `[RequireParameterlessConstructor]` (new in the base package, with `ConstructorAccessibility`) - requires every concrete type that derives from or implements it, directly or through another, to - have a parameterless constructor of the given accessibility, for the host that creates such types - by reflection. A class that declares no constructor satisfies `Public` as it is; a missing - constructor is reported at the type and the fix adds it, a wrong accessibility at the constructor - and the fix changes it; a type with a primary constructor gets no fix. A struct is never reported: - its parameterless constructor is public and can be nothing else. The code fixes ship in - `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` inside the analyzer package. -- **`CodoMetis.TypeKit`**: the text guards. `text.AsText()` is for text a person wrote: trimmed, every - space separator folded to an ordinary space, composed to NFC so one name has one spelling, and - refused when it carries a character that renders as nothing, such as a zero-width space, a Hangul - filler or NUL, which `IsNullOrWhiteSpace` lets through; the two zero-width joiners are kept, since - they spell Persian, Arabic and Indic scripts and emoji sequences. `text.AsIdentifier()` is for a - value another system issued, a subject claim or a session id: trimmed and otherwise left as it - arrived, with the joiners refused too. A variation selector is kept after the character it varies - and refused anywhere else, and a value of joiners, variation selectors and combining marks alone, - which renders nothing, is refused by both. Both also refuse, as `Hidden`, a control character (a tab - or a line break inside the value, so a multi-line field does not go through them), a format - character (the soft hyphen, the byte-order mark, the bidirectional marks), private use, the line and - paragraph separators, an unpaired surrogate and `U+FFFE`, the Khmer inherent vowels and the Khitan - filler, and more than 30 combining marks in a row, which composing would reorder in quadratic time; - the Arabic number sign and the other prepended concatenation marks are drawn and kept. No string - makes either throw. `AsText()` composes to NFC, not NFKC, and throws `PlatformNotSupportedException` - in globalization-invariant mode, where `string.Normalize` composes nothing: turn the mode off and - make ICU available, since written text has no guard there. Both answer `Result`, where `Blank` is - the caller's to read as an error or as absence and `Hidden` is a refusal in both; a null receiver - is `Blank`. A value object over either is one line, shown in the README. -- **`CodoMetis.TypeKit`**: `FirstOrNone()`, `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` - on an `IQueryable`, which hand the predicate to the query provider as an expression and ask it - for one element, a `WHERE` and a `LIMIT` in SQL. `CodoMetis.TypeKit.EntityFrameworkCore` adds - `FirstOrNoneAsync`, with or without a predicate, in EF Core's namespace; CMTK0009 names it for - `FirstOrDefaultAsync` where the project references the satellite, and names `FirstOrNone` for - `Queryable.FirstOrDefault`. -- **`CodoMetis.TypeKit.Generators`**: a closed set implements `IParsable` explicitly, beside - its own `Parse` and `TryParse`, and carries a type converter that reads and writes the member's - name, so the configuration binder binds a set and generic code parses one through `T.Parse`. A - hand-written `IParsable`, `[JsonConverter]` or `[TypeConverter]` is CMTK1011. -- **`CodoMetis.TypeKit.Serilog`**: `Destructure.WithTypeKit()` logs a closed set as what it prints, - its name or its own `ToString()`, never its constant data, which Serilog alone would destructure - by its properties. -- **`CodoMetis.TypeKit`**: an option's asynchronous steps, `MapAsync`, `BindAsync` and `TapAsync` over - callbacks that return a `Task`, and the continuations of a `Task>`: `MapAsync`, - `BindAsync`, `FilterAsync`, `TapAsync` and `ToResultAsync`, so a repository's lookup continues into - a `Result` pipeline. Each checks its delegates, and `ToResultAsync` its error, before the option is - known, as the `Task>` continuations do. -- **`CodoMetis.TypeKit`**: `SingleOrNone`, with or without a predicate, which throws for more than one - element as `SingleOrDefault` does, and `ElementAtOrNone`, by `int` or `Index` on a sequence and by - `int` on a query (two rows and one, asked of the provider; an `Index` from the end on a query is a - compile error, as `LastOrNone` is); `SingleOrNoneAsync` in the EF Core satellite; and - CMTK0009 names each for `SingleOrDefault`, `ElementAtOrDefault` and `SingleOrDefaultAsync`. `Transpose()` - between `Option>` and `Result, TError>`, and `Option.Traverse(selector)` - for an optional input validated when present. -- **`CodoMetis.TypeKit`**: `Choose(selector)` on a sequence, the values the selector answers `Some` - for, in order and lazily, a filter and a map in one; and `Choose()` on a sequence of options, the - values they hold. The arguments are checked at the call, as LINQ's are. -- **`CodoMetis.TypeKit`**: `TraverseAsync` on a sequence, an asynchronous operation or command that - may fail applied to one element at a time, in order, nothing started after the first error. With a - cancellation token, the token is checked before each operation and handed to it, and a cancellation - propagates rather than becoming an error. Operations that may all run at once remain `Task.WhenAll` - over their tasks and `Sequence()` over what it answers. -- **`CodoMetis.TypeKit.Testing`**: `ShouldBeValue()`, which hands the value back, `ShouldBeNull()` and - `ShouldBeAbsent()` on a `Patch`, failing with the state the field was in and never the value. -- **Documentation**: topic pages under `docs/`, linked from the root README's "Documentation" table: - value objects, closed sets, `Option`, `Result`, written text, type constraints, merge patches, - JSON, EF Core, ASP.NET Core, Native AOT, logging and secrets, testing, migrating from 1.5, - what builds or runs differently for code that built against it, and the comparison with the - value-object, result and smart-enum libraries, moved out of the README with its dated tables. Every C# block in them is compiled and its stated - outputs are tested, as the READMEs' are, and the `Option` and `Result` pages list the complete - public surface, checked against the types. The package READMEs remain the reference for each - package. -- **Fault responses a client can act on.** In `CodoMetis.TypeKit`: `FaultKind`, `IFault` and - `IFaultSet` (in `CodoMetis.TypeKit.ClosedSets`), a closed set whose members carry a kind and - an optional description, and `Fault`, a fault with a sentence about one occurrence, made by - `Fault.Of(reason, detail)` alone and never compared with a bare reason. In - `CodoMetis.TypeKit.AspNetCore`, each opt-in, with 1.5's default response unchanged: a closed - set as the fault type, `AddFaultResponses()` answering every member by its kind (`NotFound` 404, - `Conflict` 409, `Invalid` 422, `Forbidden` 403, `Unavailable` 503) with its description as the - `detail`; a `FaultResponse` per member with a `type` and a `title`; a `Fault`'s detail as the - problem's `detail`; `WithFaultStatus(fault, status)` on an endpoint or a route group and - `[FaultStatus]` on an MVC action or a controller, for the response and document entry of - each endpoint it covers that answers with the fault type, so a group or a controller leaves the - others, a health check, alone, and an endpoint's or action's own status replaces it; `ToHttpResult(success)`, the - endpoint's own success such as `Created`; and `IFaultFeature` on the request while the problem is - written, for the host's `CustomizeProblemDetails`, with `FaultResponseOptions.QualifiedFaultNames`, - which names a fault after its type's nesting chain (`Billing.Fault.NotFound`). - The startup check refuses a status on an endpoint or action that does not answer with its fault - type, a status on a group or controller none of whose own endpoints does, and an attribute naming - no member. +- **Closed sets** (`IClosedSet`): a fixed set of named instances with `Match`, `Parse`, JSON, EF Core, OpenAPI and Serilog support; CMTK1013 to CMTK1021 refuse what cannot be generated. +- **Fault responses a client can act on**: `Fault`, fault sets with a kind per member, `FaultResponse`, `WithFaultStatus`/`[FaultStatus]` per endpoint, group or controller, `ToHttpResult(success)` and `IFaultFeature`. +- **`Patch`**: a JSON Merge Patch field (absent, null or a value), with `Map`, `Traverse`, OpenAPI and test assertions. +- **`Result.Catch`/`CatchAsync`**: an exception as a result; a cancellation passes through, a timeout is the error. +- **Text guards**: `AsText()` and `AsIdentifier()` trim, compose to NFC (`AsText`) and refuse invisible characters. +- **Type constraints**: `[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]` (CMTK0010) and `[RequireParameterlessConstructor]` (CMTK0011, with a code fix). +- **CMTK0012**: a closed set's `Match` called by position, with a code fix. +- **`Option`**: async steps and `Task>` continuations, `SingleOrNone`, `ElementAtOrNone`, `Choose`, `Transpose`, `Traverse`; EF Core's `FirstOrNoneAsync` and `SingleOrNoneAsync`. +- **`TraverseAsync`** on a sequence: one at a time, cancellable. +- **Docs**: topic pages under `docs/`, every code block compiled. ### Changed -- **`CodoMetis.TypeKit`**: `FirstOrNone(predicate)` and `FirstOrError(predicate, error)` on an EF Core - query, or any `IQueryable`, bind to the new overloads: the predicate runs in the query, where the - `IEnumerable` ones read every row and ran it in memory. A predicate the provider cannot - translate now throws where it ran in memory before. `LastOrNone` and `LastOrError` on a query are - compile errors that say to order the query and take the first, or to call `AsEnumerable()`. -- **`CodoMetis.TypeKit.AspNetCore`**: `FaultProblem` takes any `notnull` fault type, an enum or a - closed set, where it took `struct, Enum`; every type argument it accepted is still accepted. -- **`CodoMetis.TypeKit.Analyzers`**: CMTK0003, a dropped `Result` or `Option`, is an Error where it was - a Warning. A drop that nothing depends on is correct, and `_ = …`, a cast, or a loop variable named - `_` says so in the code, so the error never needs a suppression. It also reports what it missed: - `Wait` on a task of a result the expression made; tasks of results the statement made, passed to - `Task.WaitAll` or `Task.WaitAny`; a `foreach` or `await foreach` over results its header made whose - body never reads them; and a lazy sequence of results a call made, materialised by `ToList()` or - `ToListAsync()` and dropped. CMTK0005, CMTK0006, CMTK0008 and CMTK0009 stay Warnings, since correct - code can look the same and has nothing but a suppression to say so, and CMTK0007 stays Info. -- **`CodoMetis.TypeKit.Analyzers`**: CMTK0003 also reports a result picked by `??`, a dropped tuple - holding one, a dropped dictionary of them, and a call's task passed through `ConfigureAwait` or - `WaitAsync` and dropped, awaited or not; a task the caller still holds stays silent through both. - A chain of taps on a result or a task the caller holds, `result.Tap(log).TapError(warn);` or - `await pending.TapAsync(a).TapErrorAsync(b);`, is silent, since each tap returns its receiver; 1.x - reported it. A chain that starts at a call still reports. -- **`CodoMetis.TypeKit.Generators`**: an abstract type that declares a value-object marker is CMTK1022, - an Error. It generated nothing, and a value held as it was written as `{}`; declare the marker on - the sealed type. -- **`CodoMetis.TypeKit.EntityFrameworkCore`**: `.Value`, `GetValue()` and `ValueOrNull()` are translated - only on a column TypeKit's converter stores. On a column the application maps with its own - converter they queried the wrong representation and answered wrong rows; EF now refuses the query. -- **`CodoMetis.TypeKit.EntityFrameworkCore`**: a query that compares, orders, or takes `Min`/`Max` of a - value object that declares its own `CompareTo` is refused, naming the value object. It was - translated to the column's order, the opposite of the in-memory one for a reversed ordering; order - by `.Value` where the column's order is meant. The refusal covers an ordering over a navigation and - a key cast to `object`, and a query filter that does so is refused when the model is built, naming - the entity type and the filter. -- **`CodoMetis.TypeKit.EntityFrameworkCore`**: a value-object key is declared generated on add wherever - EF declares a key of its wrapped type generated: over `sbyte`, `uint`, `ulong` and `ushort` too, and - at the root of a TPH hierarchy on SQLite, which stored 0. A TPC key on SQLite is not generated, as a - plain `int` key is not. The provider decides whether a value is generated, as for a plain key: on PostgreSQL a - migration adds an identity to a key over `sbyte`, `ushort` or `uint`, and SQL Server's migrations - generator refuses that change ("the column needs to be dropped and recreated"). A key over `char` - stays the application's to assign, on every provider, since none generates one. A key over `ulong` - added with its default value throws `OverflowException` at `Add` on SQL Server and PostgreSQL, whose - column is a `decimal`; configure `ValueGeneratedNever()` on it and assign it. -- **`CodoMetis.TypeKit.AspNetCore`**: the OpenAPI document describes `fault`, an optional string, on - the problem schema the fault responses refer to, in 3.1 and 3.0 and inline where the host inlines - problem details, with a description of the form the host writes. 1.5's `ProblemDetails` component - had only ASP.NET's members, so a client generated from it never saw `fault`. A document without a - fault response is unchanged. -- **`CodoMetis.TypeKit.AspNetCore`**: a document in which a nested value object's or closed set's - component name, `Order.Status` as `OrderStatus`, is also another type's throws, naming both; one - component described both as one of them. -- **`CodoMetis.TypeKit`**: every asynchronous combinator given a delegate that returns no task throws - an `ArgumentNullException` naming it, where the await threw a `NullReferenceException`. -- **`CodoMetis.TypeKit.Testing`**: a failure names a nested type after its containing types, - `Option`; a type nested in a generic one threw `ArgumentOutOfRangeException` instead. -- **`CodoMetis.TypeKit.Generators`**: CMTK1005 refuses a value object over `object`, which was - written to JSON as `{}`, and over `System.Enum`, and one named as a member the generators add to a - value object (`From`, `TryFrom`, `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, - `MinValue`, `MaxValue`, `CompareTo`). CMTK1021 refuses a closed set named as one of its generated - members, and CMTK1011 a hand-written `operator ==` or `!=` between two of the type. Apart from - `object`, a value object named `Revalidate` that is not validated and one named `CompareTo` over a - type with no order, each failed inside the generated code before, as LAMA0611 or a bare CS0111, - naming nothing the user wrote. -- **`CodoMetis.TypeKit.Generators`**: `TryParse` over a wrapped type parsed by a type converter, a - static `Parse` or a string constructor lets an exception from `Create` through, as `Parse` does and - as `TryParse` over a wrapped type with a `TryParse` of its own did; it answered `false`. +- The OpenAPI problem schema describes `fault`: regenerate clients. +- CMTK0003 no longer reports a chain of taps on a result the caller holds. +- EF Core: a `char` key stays the application's to assign; a default `ulong` key throws `OverflowException` at `Add` (use `ValueGeneratedNever()`). +- Testing failures name a nested type with its containing types. ### Fixed -- **`CodoMetis.TypeKit.Generators`**: a value object marked `[Obsolete(..., error: true)]`, or nested in - such a type, builds; its companion class failed with CS0619 inside the generated code. -- **`CodoMetis.TypeKit.Generators`**: a default instance of a value object over a reference type - formats as an empty string everywhere, `ToString`, `IFormattable`, `TryFormat`, the type converter - and `Convert.ToString`, as one over a `string` already did for `ToString`, where it threw a - `NullReferenceException`; its `IConvertible` answers as `Convert` answers for null. +- A value object marked `[Obsolete(error: true)]` builds. +- A default value object over a reference type formats as `""` instead of throwing. +- Where the host inlines enums, a nullable value object over one admits null in the OpenAPI document. ## 1.5.1 — 2026-10-11 From ee9214b54652bd5bc9f6b660cbe6ebfc194809f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 23:51:51 +0200 Subject: [PATCH 134/147] fix: a Patch's T is notnull, so every member hands the value as the value's type A value is never null, a clear being the Null state, yet over a nullable type every member handed it as nullable: on a Patch, p.Map(s => s.Trim()) warned (CS8602) and Traverse(Email.Create) did not match (CS8622), in a build where warnings are errors, and the docs declared Patch fields. Handing the non-null T to a Patch and a Patch alike cannot be written (measured): overload resolution ignores nullability, so one member serves both; over Patch where T : class a Patch receiver is CS8620, blocks over Patch and Patch lower to one signature (CS0111), and [DisallowNull] on a delegate's parameter does not reach a lambda's. So T is notnull, as Option's is: Patch and Patch are warned once at their declaration (CS8714), and Patch is the field that can be cleared. IfPresent over a class takes T : class and still hands a clear over as null. The docs, the samples, the OpenAPI tests and the smoke consumer declare Patch. Co-Authored-By: Claude Opus 5.5 --- design/patch.md | 10 ++ docs/merge-patches.md | 14 ++- docs/testing.md | 2 +- .../PatchAssertions.cs | 6 +- src/CodoMetis.TypeKit.Testing/README.md | 2 +- src/CodoMetis.TypeKit/Patch.cs | 24 ++-- src/CodoMetis.TypeKit/README.md | 9 +- src/CodoMetis.TypeKit/ResultExtensions.cs | 2 +- .../PatchSchemaTests.cs | 4 +- .../Base/PatchSamples.cs | 4 +- .../Docs/MergePatchesSamples.cs | 16 +-- .../Docs/TestingSamples.cs | 2 +- .../Testing/TestingSamples.cs | 2 +- .../PatchCombinationTests.cs | 2 +- .../PatchNullabilityTests.cs | 119 ++++++++++++++++++ test/CodoMetis.TypeKit.Tests/PatchTests.cs | 18 +-- test/consumer-smoke-test.sh | 2 +- 17 files changed, 190 insertions(+), 48 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Tests/PatchNullabilityTests.cs diff --git a/design/patch.md b/design/patch.md index 9b62a8a..71ec0f8 100644 --- a/design/patch.md +++ b/design/patch.md @@ -26,6 +26,16 @@ package, is the one wire type there, and is hand-written, not generated. Measure the location the serializer appended to its default sentence, which named `$`. Inside a patched object the path ends at the patched property, since a converter cannot extend the serializer's path (`PatchTests`). +- **`T` is `notnull`**, as `Option`'s is: a value is never null, a clear being the Null state. + Unconstrained, a `Patch` handed its value to `Match`, `Tap`, `Map` and `Traverse` as a + `string?`, so `p => p.Trim()` warned (CS8602) and `Traverse(Email.Create)` did not match (CS8622). + Handing the non-null `T` to a `Patch` and a `Patch` alike cannot be written + (measured 2026-10-13): overload resolution ignores nullability, so one member serves both; over + `Patch where T : class` a `Patch` receiver is CS8620, two blocks over `Patch` and + `Patch` lower to one signature (CS0111), and `[DisallowNull]` on a delegate's parameter does not + reach a lambda's (CS8602, CS8622). So the declaration is warned instead, once (CS8714), and a + `Patch`, which has no contract under a source-generated context either, with it + (`PatchNullabilityTests`). - **Writing an absent patch is refused**, since a converter cannot leave a property out; `[JsonIgnore(Condition = WhenWritingDefault)]` leaves it out instead. - **`ToString()` never prints the value**, as for `Option` and `Result`. diff --git a/docs/merge-patches.md b/docs/merge-patches.md index 97dce64..c53b322 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -20,7 +20,7 @@ public sealed partial record Email : IValidatedValue value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); } -public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); +public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); ``` ```csharp @@ -100,7 +100,9 @@ The exception's `Path` is the field's, `$.email`, as it is for a property that i answer to the client can name the field. In an endpoint, that is the 400 any body a value object refuses gets, with problem details where the host adds `UseStatusCodePages()` ([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)). A `null` is not a refusal but the `Null` state, which `Apply` turns into a fault of its own, so a `Patch` -is never needed. +is never needed. A patch's `T` is `notnull`, as an option's is: `Patch` or `Patch` is +a warning (CS8714) at the declaration, and over the value's own type every member hands the value as +it is, never null. ## Validating one @@ -136,14 +138,14 @@ skip a default: ```csharp public sealed record ContactChange( - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Floor); ``` ```csharp -string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} -string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} -JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault +string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} +string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} +JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault ``` `Patch.Value(null)` is the `Null` state, so a client that holds a nullable value can pass it as it is. diff --git a/docs/testing.md b/docs/testing.md index cdb805f..b136f6d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -60,7 +60,7 @@ same: `ShouldBeValue()` hands the value back, `ShouldBeNull()` is a clear, and ` field the document left out. ```csharp -public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); +public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); ``` ```csharp diff --git a/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs index eeb89ef..5a131c6 100644 --- a/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs +++ b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs @@ -11,7 +11,7 @@ public static class PatchAssertions /// The patch under test. /// The value. /// The field was null or not mentioned. - public static T ShouldBeValue(this Patch patch) => + public static T ShouldBeValue(this Patch patch) where T : notnull => patch.TryGetValue(out var value) ? value : throw new TypeKitAssertionException($"Expected a value, but the {Outcomes.Name>()} is {Describe(patch.State)}."); @@ -19,7 +19,7 @@ public static T ShouldBeValue(this Patch patch) => /// Fails unless the field was null: a clear. /// The patch under test. /// The field carried a value, which is not printed, or was not mentioned. - public static void ShouldBeNull(this Patch patch) + public static void ShouldBeNull(this Patch patch) where T : notnull { if (patch.State != PatchState.Null) throw new TypeKitAssertionException($"Expected Null, a clear, but the {Outcomes.Name>()} {Was(patch.State)}."); @@ -28,7 +28,7 @@ public static void ShouldBeNull(this Patch patch) /// Fails unless the field was not mentioned. /// The patch under test. /// The field carried a value, which is not printed, or was null. - public static void ShouldBeAbsent(this Patch patch) + public static void ShouldBeAbsent(this Patch patch) where T : notnull { if (patch.State != PatchState.Absent) throw new TypeKitAssertionException($"Expected Absent, but the {Outcomes.Name>()} {Was(patch.State)}."); diff --git a/src/CodoMetis.TypeKit.Testing/README.md b/src/CodoMetis.TypeKit.Testing/README.md index 76f2166..28f0cb5 100644 --- a/src/CodoMetis.TypeKit.Testing/README.md +++ b/src/CodoMetis.TypeKit.Testing/README.md @@ -20,7 +20,7 @@ Email.TryFrom("nobody").ShouldBeNone(); ``` A `Patch` field of a request says whether the request mentioned it, and the assertions say the -same; `OrderPatch` here has `Patch Name`, `Patch Nickname` and `Patch Quantity`: +same; `OrderPatch` here has `Patch Name`, `Patch Nickname` and `Patch Quantity`: ```csharp OrderPatch patch = JsonSerializer.Deserialize("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!; diff --git a/src/CodoMetis.TypeKit/Patch.cs b/src/CodoMetis.TypeKit/Patch.cs index 6de962e..7487883 100644 --- a/src/CodoMetis.TypeKit/Patch.cs +++ b/src/CodoMetis.TypeKit/Patch.cs @@ -36,8 +36,16 @@ public enum PatchState /// /// Reading never refuses a null: the Null state is part of the wire format whatever /// is, and whether a clear is accepted is the application's rule, said in . -/// A Patch<T?> over a struct is therefore never needed. A value 's -/// own converter refuses, a value object's rule, is refused as it is anywhere else. +/// A value 's own converter refuses, a value object's rule, is refused as it is +/// anywhere else. +/// +/// +/// is the value's type, notnull as an option's is, since a value is never +/// null: Patch<string> for a field that can be cleared, not Patch<string?>. Over a +/// nullable type every member would hand the value as nullable, so p => p.Trim() in +/// warned and Traverse(Email.Create) did not match; the declaration is +/// warned instead (CS8714), once. A Patch<int?> is refused the same way, and has no contract +/// under a source-generated context either. /// /// /// Writing an absent patch is refused: a converter cannot leave a property out. Mark a property that @@ -62,7 +70,7 @@ public enum PatchState /// The type of the value. [JsonConverter(typeof(PatchJsonConverterFactory))] [DebuggerDisplay("{State}")] -public readonly record struct Patch +public readonly record struct Patch where T : notnull { private readonly T? _value; private readonly PatchState _state; @@ -155,22 +163,22 @@ public static class Patch /// A field that was not mentioned: the default. /// The type of the value. /// An absent patch. - public static Patch Absent() => default; + public static Patch Absent() where T : notnull => default; /// A field that was null: a clear. /// The type of the value. /// A null patch. - public static Patch Null() => new(default, PatchState.Null); + public static Patch Null() where T : notnull => new(default, PatchState.Null); /// A field that carried ; a null value is the Null state. /// The value. /// The type of the value. /// A patch holding the value, or a null patch. - public static Patch Value(T? value) => value is null ? Null() : new(value, PatchState.Value); + public static Patch Value(T? value) where T : notnull => value is null ? Null() : new(value, PatchState.Value); /// The patch. /// The type of the value, a class. - extension(Patch patch) where T : class? + extension(Patch patch) where T : class { /// Runs with the value, or with null when the field was null, for a target a clear can reach. An absent field runs nothing. /// What to do with the value or the clear. @@ -222,7 +230,7 @@ public Patch IfPresent(Action action) /// The JSON converter behind : a JSON null is the Null state, a value is read /// with 's contract, and an absent property never reaches it. /// -internal sealed class PatchJsonConverter : JsonConverter> +internal sealed class PatchJsonConverter : JsonConverter> where T : notnull { /// A JSON null is a state, not the serializer's default. public override bool HandleNull => true; diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index b4d6f69..788ba7d 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -422,7 +422,7 @@ by hand. `Patch` is that field, and the one wire type here; `Option` refuses whether a value exists, a patch whether a request mentioned it. ```csharp -public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); ``` ```csharp @@ -445,8 +445,11 @@ order.Quantity = patch.Quantity.Match( ``` Reading never refuses a null: the Null state is part of the wire format whatever `T` is, and whether -a clear is accepted is the application's rule, said in `Match`. So a `Patch` over a struct is -never needed, and a value object inside a patch keeps its own rule: `Patch` given a value +a clear is accepted is the application's rule, said in `Match`. So a `Patch` is never needed: +`T` is the value's type and `notnull`, as an option's is, so `Patch` is a field that can be +cleared, and `Patch` is a warning (CS8714) at its declaration rather than a nullable handed +to every `Match`, `Map` and `Traverse` though a value is never null. A value object inside a patch +keeps its own rule: `Patch` given a value `Create` refuses is refused as anywhere else, the input not quoted. Where the refusal should be the endpoint's own fault, the request carries a `Patch` and `Traverse` validates it: `request.Email.Traverse(Email.Create)` is a `Result, EmailFault>`, a clear and an diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 836b8c2..4de9460 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -1337,7 +1337,7 @@ public Result, TError> Traverse(FuncA field of a merge patch, validated when it carries a value. /// The type of the value. - extension(Patch patch) + extension(Patch patch) where T : notnull { /// /// Applies an operation that may fail to the value, if the field carried one: a patch over diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index e9fb8d1..54e5c34 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -16,7 +16,7 @@ namespace CodoMetis.TypeKit.AspNetCore.Tests; /// public sealed class PatchSchemaTests { - public sealed record OrderPatch(Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines); + public sealed record OrderPatch(Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines); public sealed record Lines(int Count); @@ -41,7 +41,7 @@ public sealed record Either(string Value); /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all. public sealed record KindsPatch( - Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines, + Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines, Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either); /// The same arguments, unpatched: what each value is judged against. diff --git a/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs index df8a097..ad0a059 100644 --- a/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Base/PatchSamples.cs @@ -11,7 +11,7 @@ public sealed class Order } // sample: CodoMetis.TypeKit/patch -public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); // end sample public static class Applying @@ -33,7 +33,7 @@ public sealed class PatchTests { private const string Readme = "src/CodoMetis.TypeKit/README.md"; - private static string Render(Patch patch) => patch.Match(value => $"Value: {value}", () => "Null", () => "Absent"); + private static string Render(Patch patch) where T : notnull => patch.Match(value => $"Value: {value}", () => "Null", () => "Absent"); [Fact] public void A_document_reads_as_the_comments_state() diff --git a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs index a9cdc69..11655e3 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/MergePatchesSamples.cs @@ -22,7 +22,7 @@ public static Result Create(string value) => value.Contains('@') ? new Email(value) : Result.Error(EmailFault.NoAt); } -public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); +public sealed record ContactPatch(Patch Email, Patch Phone, Patch Floor); // end sample // sample: merge-patches/apply @@ -51,7 +51,7 @@ public static Result Apply(this Contact contact, ContactPatch patc // sample: merge-patches/writing public sealed record ContactChange( - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Phone, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Floor); // end sample @@ -125,8 +125,8 @@ public static (Result, EmailFault> Refused, Result, Em public static (string Clear, string Set) Write() { // sample: merge-patches/writing-use - string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} - string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} + string clear = JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web); // {"phone":null} + string set = JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web); // {"floor":4} // end sample return (clear, set); @@ -136,7 +136,7 @@ public static (string Clear, string Set) Write() public static void WriteRefused() { // sample: merge-patches/writing-use - JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault + JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))); // NotSupportedException naming Email and WhenWritingDefault // end sample } @@ -232,9 +232,9 @@ public void Writing_a_patch_answers_what_the_comments_say() { var (clear, set) = Usage.Write(); - clear.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web)"); - set.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web)"); - SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))), Page, input: "555"); + clear.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Null(), Patch.Absent()), JsonSerializerOptions.Web)"); + set.ShouldBeShownIn(Page, json => json, "JsonSerializer.Serialize(new ContactChange(Patch.Absent(), Patch.Value(4)), JsonSerializerOptions.Web)"); + SampleOutputs.ShouldRefuseAsShownIn(() => JsonSerializer.Serialize(new ContactPatch(Patch.Absent(), Patch.Null(), Patch.Value(4))), Page, input: "555"); Should.Throw(Usage.WriteRefused); } diff --git a/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs index 980fe1c..c5ce6fe 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/TestingSamples.cs @@ -34,7 +34,7 @@ public sealed record CodeRequest(ProductCode Code); // end sample // sample: testing/code-patch -public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); +public sealed record CodePatch(Patch Code, Patch Note, Patch Quantity); // end sample /// The page's tests, run as written. diff --git a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs index 3103272..971af9e 100644 --- a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs @@ -9,7 +9,7 @@ namespace ReadmeSamples.Testing; -public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); +public sealed record OrderPatch(Patch Name, Patch Nickname, Patch Quantity); public sealed partial class TestingSamples { diff --git a/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs b/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs index 5d8e5b2..e60b615 100644 --- a/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs +++ b/test/CodoMetis.TypeKit.Tests/PatchCombinationTests.cs @@ -9,7 +9,7 @@ public sealed class PatchCombinationTests { private static Result Positive(int n) => n > 0 ? n : Result.Error($"{n} is not positive"); - private static string Show(Patch patch) => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); + private static string Show(Patch patch) where T : notnull => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); [Fact] public void Map_changes_the_value_and_keeps_a_clear_and_an_absence() diff --git a/test/CodoMetis.TypeKit.Tests/PatchNullabilityTests.cs b/test/CodoMetis.TypeKit.Tests/PatchNullabilityTests.cs new file mode 100644 index 0000000..b61b9c2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Tests/PatchNullabilityTests.cs @@ -0,0 +1,119 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CodoMetis.TypeKit.Tests; + +/// +/// What a consumer's compiler says about and nullability. A value is never +/// null, a clear being the Null state, so every member hands the value as the value's type; over a +/// nullable type it handed a string? to Match, Tap, Map and Traverse, and +/// p.Map(s => s.Trim()) on a Patch<string?> warned (CS8602) while +/// Traverse(Email.Create) did not match (CS8622), in a build where warnings are errors. +/// +/// +/// Overload resolution ignores nullability, so no overload hands a Patch<string?> its value as a +/// string without warning a Patch<string> (design/patch.md has the measurements). T +/// is notnull instead, as an option's is: the nullable declaration is warned, once, where it is written. +/// +public sealed class PatchNullabilityTests +{ + private const string Types = """ + using System; + using CodoMetis.TypeKit; + + public enum F { Bad } + public readonly record struct Code(string Value); + public sealed record Name(string Value); + """; + + /// Each member, on a patch over , its value used as non-null. + private static string EveryMember(string type, string use, string check) => + $$""" + {{Types}} + + public static class Checks + { + static Result<{{type}}, F> Check({{type}} value) => {{check}} ? value : Result.Error(F.Bad); + + public static void Run(Patch<{{type}}> patch) + { + Patch mapped = patch.Map(value => {{use}}); + Result, F> validated = patch.Traverse(Check); + int matched = patch.Match(value => {{use}}, () => 0, () => -1); + if (patch.TryGetValue(out var held)) _ = {{use.Replace("value", "held")}}; + patch.Tap(value => _ = {{use}}); + Option<{{type}}> option = patch.ToOption(); + Patch<{{type}}> same = patch.IfPresent(value => _ = value); + } + } + """; + + [Theory] + [InlineData("string", "value.Length", "value.Length > 0")] + [InlineData("Name", "value.Value.Length", "value.Value.Length > 0")] + [InlineData("int", "value + 1", "value > 0")] + [InlineData("Code", "value.Value.Length", "value.Value.Length > 0")] + public void Every_member_hands_the_value_as_the_value_s_type_without_a_warning(string type, string use, string check) => + ConsumerCompilation.Of(EveryMember(type, use, check)).Problems().ShouldBeEmpty(); + + /// + /// The one place a nullable type argument is told: its declaration, each of them, whether a + /// property, a parameter or a factory's type argument. + /// + [Fact] + public void A_patch_over_a_nullable_type_is_warned_where_it_is_declared() + { + var compilation = ConsumerCompilation.Of($$""" + {{Types}} + + public sealed class Request + { + public Patch Nickname { get; init; } + public Patch Owner { get; init; } + public Patch Floor { get; init; } + } + + public static class Clears + { + public static Patch Nickname() => Patch.Null(); + } + """); + + var warnings = compilation.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning).ToList(); + + warnings.Select(warning => warning.Id).Distinct().ShouldBe(["CS8714"], compilation.Problems()); + warnings.Select(warning => warning.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).ShouldContain(message => message.Contains("'string?'"), compilation.Problems()); + warnings.Select(warning => warning.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).ShouldContain(message => message.Contains("'Name?'"), compilation.Problems()); + warnings.Select(warning => warning.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).ShouldContain(message => message.Contains("'int?'"), compilation.Problems()); + warnings.Count.ShouldBe(5, compilation.Problems()); + } + + /// + /// Which member each call binds to, so that a member added later is seen to change a binding: the + /// clear reaches IfPresent as null, through T?, over a class and over a struct. + /// + [Theory] + [InlineData("string", "System.Action")] + [InlineData("Name", "System.Action")] + [InlineData("int", "System.Action")] + [InlineData("Code", "System.Action")] + public void IfPresent_hands_a_clear_over_as_null_over_a_class_and_a_struct(string type, string action) + { + var compilation = ConsumerCompilation.Of($$""" + {{Types}} + + public static class Checks + { + public static void Run(Patch<{{type}}> patch) => patch.IfPresent(value => { }); + } + """); + + compilation.Problems().ShouldBeEmpty(); + + var tree = compilation.SyntaxTrees.Single(); + var call = tree.GetRoot(TestContext.Current.CancellationToken).DescendantNodes().OfType().Single(); + var method = (IMethodSymbol)compilation.GetSemanticModel(tree).GetSymbolInfo(call, TestContext.Current.CancellationToken).Symbol!; + + method.Parameters.Single().Type.ToDisplayString().ShouldBe(action); + } +} diff --git a/test/CodoMetis.TypeKit.Tests/PatchTests.cs b/test/CodoMetis.TypeKit.Tests/PatchTests.cs index 7e38504..a9dd0f4 100644 --- a/test/CodoMetis.TypeKit.Tests/PatchTests.cs +++ b/test/CodoMetis.TypeKit.Tests/PatchTests.cs @@ -18,7 +18,7 @@ public sealed class PatchTests private static JsonSerializerOptions Options(string resolver) => resolver == "reflection" ? Reflection : Generated; - private static string Show(Patch patch) => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); + private static string Show(Patch patch) where T : notnull => patch.Match(value => $"Value({value})", () => "Null", () => "Absent"); [Fact] public void Default_is_absent_and_the_states_are_told_apart() @@ -29,7 +29,7 @@ public void Default_is_absent_and_the_states_are_told_apart() Patch.Absent().ShouldBe(absent); Patch.Null().State.ShouldBe(PatchState.Null); Patch.Value(3).State.ShouldBe(PatchState.Value); - Patch.Value(null).State.ShouldBe(PatchState.Null, "a null value is the Null state"); + Patch.Value(null).State.ShouldBe(PatchState.Null, "a null value is the Null state"); } [Fact] @@ -228,10 +228,10 @@ public void Writing_leaves_an_absent_patch_out_under_WhenWritingDefault_and_refu { var options = Options(resolver); - JsonSerializer.Serialize(new Outgoing(Patch.Null(), Patch.Absent()), options).ShouldBe("""{"name":null}"""); - JsonSerializer.Serialize(new Outgoing(Patch.Value("Ada"), Patch.Value(2)), options).ShouldBe("""{"name":"Ada","quantity":2}"""); + JsonSerializer.Serialize(new Outgoing(Patch.Null(), Patch.Absent()), options).ShouldBe("""{"name":null}"""); + JsonSerializer.Serialize(new Outgoing(Patch.Value("Ada"), Patch.Value(2)), options).ShouldBe("""{"name":"Ada","quantity":2}"""); - var refusal = Should.Throw(() => JsonSerializer.Serialize(new BareOutgoing(Patch.Absent()), options)); + var refusal = Should.Throw(() => JsonSerializer.Serialize(new BareOutgoing(Patch.Absent()), options)); refusal.Message.ShouldContain("WhenWritingDefault"); } @@ -264,18 +264,18 @@ public sealed class CodeConverter : JsonConverter public override void Write(Utf8JsonWriter writer, Code value, JsonSerializerOptions options) => writer.WriteStringValue(value.Value); } - public sealed record Update(Patch Name, Patch Quantity, Patch Code, Patch Address); + public sealed record Update(Patch Name, Patch Quantity, Patch Code, Patch
Address); /// The same field without a patch, whose refusal is the one a patch's should match. public sealed record Plain(int Quantity); - public sealed record UpdateWithoutAddress(Patch Address); + public sealed record UpdateWithoutAddress(Patch
Address); public sealed record Outgoing( - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Name, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Name, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] Patch Quantity); - public sealed record BareOutgoing(Patch Name); + public sealed record BareOutgoing(Patch Name); } // A class a patch wraps is listed, since the patch's converter hides it from the generator; a primitive diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index 753dbc3..c804b85 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -788,7 +788,7 @@ public sealed record Line(OrderId Id, Code Code, Name Name, Quantity Quantity, A public sealed record OrderView(OrderId Id, List Quantities, Amount Amount); -public sealed record OrderPatch(Patch Code, Patch Note); +public sealed record OrderPatch(Patch Code, Patch Note); public sealed record Holder(Option Value); From 189ab9f1e4e2192733c14c715521fbbe8a8ff662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 00:00:43 +0200 Subject: [PATCH 135/147] fix: Choose(selector) reads a sequence whose elements may be null Choose(selector) sat with the sequence members whose T is notnull, because their element becomes an option's value; Choose's element is only the selector's argument, and only what the selector finds must be non-null. So IEnumerable.Choose(s => s.ToOption()) warned CS8714 and CS8622, and IEnumerable CS8714, where warnings are errors. It moves to an unconstrained block over IEnumerable, in a class of its own, SequenceOptionExtensions: beside Option's notnull sequence block, a second block over IEnumerable regroups both in metadata, and package validation read that as FirstOrNone and LastOrNone removed from 1.5.1 (CP0002, CP0021; measured), as ValueTypeOptionExtensions already sits apart for OrNull. The receiver is a sequence, never a bare T, so decision 31 holds, and Choose() over options is unchanged. The option page's surface table reads the new class and says the elements may be null. Co-Authored-By: Claude Opus 5.5 --- docs/option.md | 5 ++- src/CodoMetis.TypeKit/OptionExtensions.cs | 18 +-------- src/CodoMetis.TypeKit/README.md | 3 +- .../SequenceOptionExtensions.cs | 38 +++++++++++++++++++ .../Docs/OptionSamples.cs | 2 +- test/CodoMetis.TypeKit.Tests/ChooseTests.cs | 35 +++++++++++++++++ .../NullDelegateTests.cs | 2 +- 7 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 src/CodoMetis.TypeKit/SequenceOptionExtensions.cs diff --git a/docs/option.md b/docs/option.md index 628fa52..17e11ce 100644 --- a/docs/option.md +++ b/docs/option.md @@ -130,7 +130,8 @@ the caller has to act on, which hands the rest to [Result](result.md). ## The surface Every public member, on `Option`, on the static class `Option`, and as extensions in the -`CodoMetis.TypeKit` namespace. `T` is `notnull` throughout. +`CodoMetis.TypeKit` namespace. `T` is `notnull` throughout, but for the elements `Choose(selector)` +reads, which may be null: only what the selector finds is a value. | Member | On | Returns | |---|---|---| @@ -143,7 +144,7 @@ Every public member, on `Option`, on the static class `Option`, and as extens | `SingleOrNone()`, `SingleOrNone(predicate)` | `IEnumerable`, `IQueryable` | the only element, or `None` for none; throws for more than one. A query is asked for two rows | | `ElementAtOrNone(index)` | `IEnumerable` (an `int` or an `Index`), `IQueryable` (an `int`) | the element at the index, or `None` past either end; an `Index` on a query is a compile error | | `GetValueOrNone(key)` | `IReadOnlyDictionary` | `Option` | -| `Choose(selector)` | `IEnumerable` | the values the selector answers `Some` for, in order and lazily: a filter and a map in one | +| `Choose(selector)` | `IEnumerable`, its elements nullable or not | the values the selector answers `Some` for, in order and lazily: a filter and a map in one | | `Choose()` | `IEnumerable>` | the values of the options that hold one, in order and lazily | | `IsSome()`, `IsNone()` | `Option` | `bool` | | `Match(onSome, onNone)` | `Option` | what the branch returns | diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index 7c58cb3..2ede748 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -493,22 +493,6 @@ public Option ElementAtOrNone(Index index) var last = source.TakeLast(index.Value).ToList(); return last.Count == index.Value ? Some(last[0]) : None(); } - - /// - /// The values finds: for each element it answers with Some, - /// that value, in order, and nothing for a None. A filter and a map in one, as F#'s - /// choose, and lazy, as a LINQ operator is. - /// - /// Called with each element as the sequence is read. - /// The type of the values. - /// The values found, in order. - public IEnumerable Choose(Func> selector) where TResult : notnull - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(selector); - - return Chosen(source, selector); - } } /// The query. @@ -679,7 +663,7 @@ public IEnumerable Choose() } /// The iterator behind both Choose, apart so the arguments are checked when the call is made, not when the sequence is read. - private static IEnumerable Chosen(IEnumerable source, Func> selector) where TResult : notnull + internal static IEnumerable Chosen(IEnumerable source, Func> selector) where TResult : notnull { foreach (var element in source) { diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 788ba7d..dd953e2 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -57,7 +57,8 @@ the element they need and let it run the predicate (a `WHERE` and a `LIMIT` in S `LastOrError` and an index from the end are compile errors there, since a query has no last element until it is ordered. `Choose(selector)` keeps the values a selector answers `Some` for, in order and without the `None`s, a -filter and a map in one, and `Choose()` the values a sequence of options holds. +filter and a map in one, over elements that may be null (`nicknames.Choose(n => n.ToOption())`), and +`Choose()` the values a sequence of options holds. An optional input that is validated when present is `Traverse`: `dto.Phone.ToOption().Traverse(Phone.Create)` is a `Result, PhoneFault>`, and `Transpose()` turns an `Option>` inside out, and a `Result>` back. diff --git a/src/CodoMetis.TypeKit/SequenceOptionExtensions.cs b/src/CodoMetis.TypeKit/SequenceOptionExtensions.cs new file mode 100644 index 0000000..d74e993 --- /dev/null +++ b/src/CodoMetis.TypeKit/SequenceOptionExtensions.cs @@ -0,0 +1,38 @@ +namespace CodoMetis.TypeKit; + +/// +/// Choose(selector) over a sequence whose elements may be null: only what the selector finds +/// must be non-null, so a sequence of string? or int? is chosen from without a warning, +/// its nulls the selector's to answer. The other sequence members, Choose() over options among +/// them, are on . +/// +/// +/// A class of its own. 's sequence members take a notnull element, since it +/// becomes an option's value, and a second block over beside them, without +/// the constraint, regroups them in the assembly's metadata, which package validation reads as +/// FirstOrNone and LastOrNone removed from 1.5.1 (CP0002, CP0021; measured). The receiver is +/// a sequence, never an unconstrained T itself (decision 31). +/// +public static class SequenceOptionExtensions +{ + /// The sequence, whose elements may be null. + /// The type of the elements, nullable or not. + extension(IEnumerable source) + { + /// + /// The values finds: for each element it answers with Some, + /// that value, in order, and nothing for a None. A filter and a map in one, as F#'s + /// choose, and lazy, as a LINQ operator is. + /// + /// Called with each element as the sequence is read. + /// The type of the values. + /// The values found, in order. + public IEnumerable Choose(Func> selector) where TResult : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); + + return Option.Chosen(source, selector); + } + } +} diff --git a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs index 3cd37b2..785a7a6 100644 --- a/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Docs/OptionSamples.cs @@ -221,7 +221,7 @@ public void The_surface_table_names_every_public_member() var page = SampleOutputs.Readme(Readme); var surface = page[page.IndexOf("## The surface", StringComparison.Ordinal)..]; - var members = PublicMembers(typeof(Option<>), typeof(Option), typeof(ValueTypeOptionExtensions)); + var members = PublicMembers(typeof(Option<>), typeof(Option), typeof(ValueTypeOptionExtensions), typeof(SequenceOptionExtensions)); members.Length.ShouldBeGreaterThanOrEqualTo(20, string.Join(", ", members)); members.Where(member => !Regex.IsMatch(surface, $@"`{Regex.Escape(member)}[`(<,]")).ShouldBeEmpty("Every public member has a row in the surface table."); diff --git a/test/CodoMetis.TypeKit.Tests/ChooseTests.cs b/test/CodoMetis.TypeKit.Tests/ChooseTests.cs index 377b5e6..4d68d8d 100644 --- a/test/CodoMetis.TypeKit.Tests/ChooseTests.cs +++ b/test/CodoMetis.TypeKit.Tests/ChooseTests.cs @@ -59,4 +59,39 @@ public void Choose_checks_its_arguments_before_the_sequence_is_read() Should.Throw(() => ((IEnumerable>)null!).Choose()).ParamName.ShouldBe("options"); Should.Throw(() => new[] { 1 }.Choose((Func>)null!)).ParamName.ShouldBe("selector"); } + + /// + /// Only what the selector finds must be non-null; the elements it reads may be null, and its answer + /// for a null is its own. The elements' T was notnull, so a sequence of string? + /// or int? warned (CS8714, and CS8622 for the lambda) where warnings are errors. + /// + [Fact] + public void Choose_with_a_selector_reads_a_sequence_with_null_elements() + { + string?[] nicknames = ["Annie", null, "Bo"]; + int?[] floors = [3, null, 5]; + + nicknames.Choose(nickname => nickname.ToOption()).ShouldBe(["Annie", "Bo"]); + floors.Choose(floor => floor.ToOption()).ShouldBe([3, 5]); + nicknames.Choose(nickname => nickname is null ? Option.Some("none") : Option.None()).ShouldBe(["none"]); + } + + /// The same calls in a consumer's compilation, nullable annotations on: no warning at all. + [Theory] + [InlineData("IEnumerable nicknames", "nicknames.Choose(nickname => nickname is null ? Option.None() : Option.Some(nickname))")] + [InlineData("IEnumerable nicknames", "nicknames.Choose(nickname => nickname.ToOption())")] + [InlineData("IEnumerable floors", "floors.Choose(floor => floor.ToOption())")] + [InlineData("IEnumerable floors", "floors.Choose(floor => floor is { } value ? Option.Some(value * 10) : Option.None())")] + [InlineData("IEnumerable numbers", "numbers.Choose(n => n > 0 ? Option.Some(n) : Option.None())")] + [InlineData("IEnumerable> options", "options.Choose()")] + public void Choose_compiles_without_a_warning_over_nullable_and_non_nullable_elements(string parameter, string call) => + ConsumerCompilation.Of($$""" + using System.Collections.Generic; + using CodoMetis.TypeKit; + + public static class Checks + { + public static object Run({{parameter}}) => {{call}}; + } + """).Problems().ShouldBeEmpty(); } diff --git a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs index b2babd5..a25e6b8 100644 --- a/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs +++ b/test/CodoMetis.TypeKit.Tests/NullDelegateTests.cs @@ -102,7 +102,7 @@ public sealed class NullDelegateTests ["Patch.Map(Func): selector"] = [() => Patch.Value(1).Map(Null>()), () => Patch.Null().Map(Null>()), () => Patch.Absent().Map(Null>())], ["Result.Traverse(Patch, Func>): selector"] = [() => Patch.Value(1).Traverse(Null>>()), () => Patch.Null().Traverse(Null>>()), () => Patch.Absent().Traverse(Null>>())], - ["Option.Choose(IEnumerable, Func>): selector"] = [() => new[] { 1 }.Choose(Null>>()), () => Array.Empty().Choose(Null>>())], + ["SequenceOptionExtensions.Choose(IEnumerable, Func>): selector"] = [() => new[] { 1 }.Choose(Null>>()), () => Array.Empty().Choose(Null>>())], ["Result.TraverseAsync(IEnumerable, Func>>): selector"] = [() => new[] { 1 }.TraverseAsync(Null>>>()), () => Array.Empty().TraverseAsync(Null>>>())], ["Result.TraverseAsync(IEnumerable, Func>>, CancellationToken): selector"] = From 9422c210589d349f3ecf05e66489692335b77356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 00:01:36 +0200 Subject: [PATCH 136/147] docs: a Func or a method group given to FirstOrNone on a query reads every row A lambda written at the call is an expression and binds to the query's FirstOrNone, SingleOrNone and FirstOrError, which the provider runs. A Func held in a variable, or a method group, is not: it binds to the sequence's overloads, which read the whole table and run the condition in memory, without a warning. The option page, the EF guide, the base and EF READMEs and the sequence overloads' XML remarks say so and name the way out, an Expression>; FirstOrNoneAsync takes only an expression. An EF test pins the SQL: no WHERE and no LIMIT for a Func and a method group, a WHERE for an expression held in a variable. No analyzer yet. Co-Authored-By: Claude Opus 5.5 --- docs/ef-core.md | 6 ++++ docs/option.md | 11 +++--- .../README.md | 6 +++- src/CodoMetis.TypeKit/OptionExtensions.cs | 9 +++++ src/CodoMetis.TypeKit/README.md | 4 ++- src/CodoMetis.TypeKit/ResultExtensions.cs | 4 +++ .../QueryOptionTests.cs | 35 +++++++++++++++++++ 7 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/ef-core.md b/docs/ef-core.md index 5a16b78..b000f99 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -187,6 +187,12 @@ the domain wants an option: Option discount = read.Discount.ToOption(); // None ``` +An element of a query as an option is `FirstOrNone`, `SingleOrNone` or `FirstOrNoneAsync`, which let +the database run the condition and read one row ([the EF README](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#an-option-for-nothing-found)). +Only a condition written as a lambda at the call, or held as an `Expression>`, runs there: +a `Func` variable or a method group binds the synchronous ones to their overloads on a sequence, +which read every row of the table and run the condition in memory, without a warning. + ## Compiled models EF Core runs under Native AOT only through a compiled model and precompiled queries, and diff --git a/docs/option.md b/docs/option.md index 17e11ce..47105b8 100644 --- a/docs/option.md +++ b/docs/option.md @@ -34,9 +34,12 @@ write `Option.None()`. The lookups do the rest: `GetValueOrNone(key)` on a di throws for more than one, `ElementAtOrNone(index)`, and `ToOption()` on a nullable, `int?` or `string?`, which turns a null into `None`. On a query, `FirstOrNone`, `SingleOrNone` and `ElementAtOrNone` let the provider run the predicate and ask it for the rows they need, and -`LastOrNone` is a compile error, since a query has no last row until it is ordered. CMTK0009 points -at `FirstOrDefault()` and its kind, whose `default` for a value object or a `Result` is an instance -that passed no factory. +`LastOrNone` is a compile error, since a query has no last row until it is ordered. The predicate +runs in the query when it is an expression, as a lambda written at the call is; a `Func` held +in a variable, or a method group, binds to the sequence's overload instead, which reads every row and +runs the condition in memory, without a warning. Pass an `Expression>` there. CMTK0009 +points at `FirstOrDefault()` and its kind, whose `default` for a value object or a `Result` is an +instance that passed no factory. ## Reaching the value @@ -139,7 +142,7 @@ reads, which may be null: only what the selector finds is a value. | `None()` | `Option` | a `None` marker that converts to any `Option` | | `None()` | `Option` | `Option` | | `ToOption()` | `T?` (a nullable struct or a reference) | `Option`: `None` for null | -| `FirstOrNone()`, `FirstOrNone(predicate)` | `IEnumerable`, `IQueryable` | the first element, or the first matching one; `None` for none. On a query the provider runs the predicate and returns one row | +| `FirstOrNone()`, `FirstOrNone(predicate)` | `IEnumerable`, `IQueryable` | the first element, or the first matching one; `None` for none. On a query the provider runs the predicate and returns one row, when the predicate is an expression; a `Func` variable or a method group reads every row | | `LastOrNone()`, `LastOrNone(predicate)` | `IEnumerable` | the last, likewise; a compile error on a query, which has no last row until it is ordered | | `SingleOrNone()`, `SingleOrNone(predicate)` | `IEnumerable`, `IQueryable` | the only element, or `None` for none; throws for more than one. A query is asked for two rows | | `ElementAtOrNone(index)` | `IEnumerable` (an `int` or an `Index`), `IQueryable` (an `int`) | the element at the index, or `None` past either end; an `Index` on a query is a compile error | diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 0c96579..24db417 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -180,7 +180,11 @@ Result found = db.Orders.FirstOrError(o => o.Id == id, OrderF database for one row and let it run the condition, `WHERE … LIMIT @p`, where `FirstOrDefaultAsync` hands back a `default` value object for nothing found (CMTK0009). `SingleOrNoneAsync` and `SingleOrNone` ask for two rows and throw for more than one, and `ElementAtOrNone` skips to its row, -`OFFSET @p`. On a query, `LastOrNone` and +`OFFSET @p`. That holds for a condition EF can read, an expression, which a lambda written at the +call is. A `Func` held in a variable, or a method group, binds `FirstOrNone`, +`SingleOrNone` and `FirstOrError` to their overloads on a sequence: every row is read and the +condition runs in memory, without a warning. Hold an `Expression>` instead; +`FirstOrNoneAsync` takes only an expression, so a `Func` there does not compile. On a query, `LastOrNone` and `LastOrError` are compile errors: a query has no last row until it is ordered, so order it the other way and take the first, or call `AsEnumerable()` to read every row on purpose. diff --git a/src/CodoMetis.TypeKit/OptionExtensions.cs b/src/CodoMetis.TypeKit/OptionExtensions.cs index 2ede748..525afb8 100644 --- a/src/CodoMetis.TypeKit/OptionExtensions.cs +++ b/src/CodoMetis.TypeKit/OptionExtensions.cs @@ -419,6 +419,11 @@ public Option LastOrNone() } /// The first element that matches, or None. + /// + /// On an , this overload is the one a Func held in a variable or a + /// method group binds to: it reads every row and runs the condition in memory. A lambda written at + /// the call, or an Expression<Func<T, bool>>, binds to the query's, which the provider runs. + /// /// The condition to match. /// The first matching element, or None if nothing matches. public Option FirstOrNone(Func predicate) => @@ -451,6 +456,10 @@ public Option SingleOrNone() } /// The only element that matches, or None; more than one throws, as SingleOrDefault does. + /// + /// On an , a Func held in a variable or a method group binds here + /// and reads every row; a lambda written at the call binds to the query's overload. + /// /// The condition to match. /// The only matching element, or None if nothing matches. /// More than one element matches. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index dd953e2..5964146 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -55,7 +55,9 @@ not absence; `ElementAtOrNone(index)` answers the element at an index, on a sequ On a query, `FirstOrNone`, `FirstOrError`, `SingleOrNone` and `ElementAtOrNone` ask the provider for the element they need and let it run the predicate (a `WHERE` and a `LIMIT` in SQL). `LastOrNone`, `LastOrError` and an index from the end are compile errors there, since a query has no last element -until it is ordered. +until it is ordered. The predicate is an expression when the lambda is written at the call; a +`Func` variable or a method group is not, binds to the sequence's overload, and reads every +row, the condition run in memory. Hold an `Expression>` instead. `Choose(selector)` keeps the values a selector answers `Some` for, in order and without the `None`s, a filter and a map in one, over elements that may be null (`nicknames.Choose(n => n.ToOption())`), and `Choose()` the values a sequence of options holds. diff --git a/src/CodoMetis.TypeKit/ResultExtensions.cs b/src/CodoMetis.TypeKit/ResultExtensions.cs index 4de9460..5e7b56b 100644 --- a/src/CodoMetis.TypeKit/ResultExtensions.cs +++ b/src/CodoMetis.TypeKit/ResultExtensions.cs @@ -1009,6 +1009,10 @@ public async Task> TapAsync(Func action) extension(IEnumerable source) where T : notnull where TError : notnull { /// The first element that matches, or the given error. + /// + /// On an , a Func held in a variable or a method group binds here + /// and reads every row; a lambda written at the call binds to the query's overload. + /// /// The condition to match. /// The error if nothing matches. Never null. /// The first matching element, or . diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs index c0baa29..2270987 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryOptionTests.cs @@ -73,6 +73,41 @@ public async Task A_condition_only_the_database_can_run_is_translated() result.Carrier.ShouldBe("alpha"); } + /// + /// What the documentation says of a condition EF cannot read. A lambda written at the call is an + /// expression and binds to the query's overload; a Func held in a variable, or a method group, + /// binds to the sequence's, which reads every row and runs the condition in memory. No analyzer + /// tells it yet, so this pins the behaviour the pages describe. + /// + [Fact] + public void A_Func_or_a_method_group_reads_every_row_and_runs_the_condition_in_memory() + { + using var db = Db(); + Func isBeta = s => s.Carrier == "beta"; + + db.Shipments.FirstOrNone(isBeta).TryGetValue(out var held).ShouldBeTrue(); + held.Carrier.ShouldBe("beta"); + var read = _commands.ShouldHaveSingleItem(); + read.ShouldNotContain("WHERE"); + read.ShouldNotContain("LIMIT"); + + _commands.Clear(); + db.Shipments.SingleOrNone(IsGamma).TryGetValue(out var grouped).ShouldBeTrue(); + grouped.Carrier.ShouldBe("gamma"); + _commands.ShouldHaveSingleItem().ShouldNotContain("WHERE"); + + _commands.Clear(); + db.Shipments.FirstOrError(isBeta, "none").TryGetValue(out _, out _).ShouldBeTrue(); + _commands.ShouldHaveSingleItem().ShouldNotContain("WHERE"); + + _commands.Clear(); + System.Linq.Expressions.Expression> isAlpha = s => s.Carrier == "alpha"; + db.Shipments.FirstOrNone(isAlpha).TryGetValue(out _).ShouldBeTrue(); + _commands.ShouldHaveSingleItem().ShouldContain("WHERE", Case.Sensitive, "an expression held in a variable still runs in the query"); + } + + private static bool IsGamma(Shipment shipment) => shipment.Carrier == "gamma"; + /// The database runs the condition and is asked for two rows: enough to tell one from many. [Fact] public async Task SingleOrNone_asks_for_two_rows_and_refuses_more_than_one() From 029af8b1bba0ea019a497d1febce027343a7b761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 00:05:23 +0200 Subject: [PATCH 137/147] docs: the roadmap keeps what is left for 2.1, and the changelog and guide carry Patch's notnull T and FirstOrNone over a Func Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- design/roadmap.md | 40 +++++++++++++++++++++------------------- docs/migrating-to-2.md | 6 ++++-- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbfb4e5..c3ae4dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Still `net10.0` and Metalama 2026.1. What builds or runs differently, and what t ### Breaking - CMTK0003, a dropped `Result` or `Option`, is an Error, and reports more forms (`Wait()`, loops that never read, `??`, tuples). -- `FirstOrNone`/`FirstOrError` on an `IQueryable` run in the query; `LastOrNone`/`LastOrError` on a query no longer compile. +- `FirstOrNone`/`FirstOrError` on an `IQueryable` run in the query (a `Func` variable still reads every row); `LastOrNone`/`LastOrError` on a query no longer compile. - CMTK1022 refuses an abstract value object; CMTK1005 refuses one over `object` or `System.Enum`, or named as a generated member. - A generated `TryParse` lets an exception from `Create` through instead of answering `false`. - EF Core refuses `.Value` on a column with the application's own converter, and ordering by a hand-written `CompareTo`, in queries and query filters. @@ -31,7 +31,7 @@ Still `net10.0` and Metalama 2026.1. What builds or runs differently, and what t - **Closed sets** (`IClosedSet`): a fixed set of named instances with `Match`, `Parse`, JSON, EF Core, OpenAPI and Serilog support; CMTK1013 to CMTK1021 refuse what cannot be generated. - **Fault responses a client can act on**: `Fault`, fault sets with a kind per member, `FaultResponse`, `WithFaultStatus`/`[FaultStatus]` per endpoint, group or controller, `ToHttpResult(success)` and `IFaultFeature`. -- **`Patch`**: a JSON Merge Patch field (absent, null or a value), with `Map`, `Traverse`, OpenAPI and test assertions. +- **`Patch`**: a JSON Merge Patch field (absent, null or a value; `T` is `notnull`, so `Patch` is a clearable string), with `Map`, `Traverse`, OpenAPI and test assertions. - **`Result.Catch`/`CatchAsync`**: an exception as a result; a cancellation passes through, a timeout is the error. - **Text guards**: `AsText()` and `AsIdentifier()` trim, compose to NFC (`AsText`) and refuse invisible characters. - **Type constraints**: `[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]` (CMTK0010) and `[RequireParameterlessConstructor]` (CMTK0011, with a code fix). diff --git a/design/roadmap.md b/design/roadmap.md index bfdca18..1ecda75 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -16,25 +16,27 @@ item names the decision that scoped it. - An optional `Cause` on `Fault`, an exception kept beside the reason and the detail for the log and the fault feature, never written to the problem; `Fault.Of(reason, detail, cause)`, and a `MapError` from a `Result` that keeps the exception ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)). -- What the outside review of 2026-10-13 rated can-wait: the fault - name is sent even for a member without a description; `Uri.ToString()` unescapes the problem - `type`; the CMTK0011 code fix on a partial type whose other part has a primary constructor, and its - doc comment beside an attribute; CMTK0003 over `WhenAny` of held tasks; `Choose` on a sequence with - a nullable element; `Patch` under `Traverse` and `Map`. -- CMTK1021 groups names ignoring case, which misses the one `Match` collision `ToLowerInvariant` makes - and ordinal case folding does not (the Kelvin sign); it fails loudly, as LAMA0611 and CS0100. -- What the second outside review of 2026-10-13 rated can-wait. Analyzers: the trivia the CMTK0011 - code fix drops; CMTK0010 on an enum or a delegate under `[Concrete]` and on a type parameter whose - constraint already makes it a record, and its cost (`GetSymbolInfo` on every generic name, about - 0.5 s on a 6.7k-line file); CMTK0003 on an async expression-bodied member in the IDE, where - Metalama has not run. Generators: a C# keyword in a namespace or a member name fails with LAMA0041, - not a CMTK error; generated members carry no XML documentation, so IntelliSense shows nothing for - them. Run time: the Serilog policy ignores `ToMaximumStringLength`; an inlined nullable closed set - does not admit null in the document; an exception error prints its whole `ToString()` in a Testing - message, and there are no async assertions; `query.FirstOrNone(func)` binds to the sequence - overload and reads every row; a closed set's `.Name` in a query is not translated (it throws); the - closed-set JSON read allocates a string per token; `CatchAsync` over a `ValueTask` and - `TraverseAsync` on `Option` and `Patch`. +- An opt-in that withholds the `fault` name from the response for chosen members or a whole fault + type, so two causes can share a status without a mapping at the boundary; additive + ([decision 64](adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md)). +- CMTK0009, a Warning, which may learn ([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)), + reports `FirstOrNone`, `SingleOrNone` and `FirstOrError` on an `IQueryable` given a + `Func` or a method group, which binds to the sequence overload and reads every row. +- Documentation for generated members, so IntelliSense shows them: it needs Metalama to copy a + template's XML comment to the member it introduces, or to take one through the builder, measured + absent in 2026.1.29 and 2027.0.3-preview (`spikes/IntroducedMemberDocs`). Ask upstream; then + document every generated member and test a consumer's XML documentation file. +- A default value-object key over `ulong` generated as a plain one is, which needs a per-key value + generator a compiled model can write ([decision 63](adr/0063-char-keys-application-assigned-ulong-documented.md)). +- CMTK0003: `WhenAny` over held tasks; an async expression-bodied member in the IDE, where Metalama has + not run; and decide whether a tap on a property or an indexer, and a result a cache's `GetOrAdd` + still holds, are drops, which it reports today. +- CMTK0010's cost: `GetSymbolInfo` on every generic name, about 0.5 s on a 6.7k-line file. +- Generators: a C# keyword in a namespace or a member name fails with LAMA0041, not a CMTK error. +- Run time: the Serilog policy ignores `ToMaximumStringLength`; an exception error prints its whole + `ToString()` in a Testing message, and there are no async assertions; a closed set's `.Name` in a + query is not translated (it throws); the closed-set JSON read allocates a string per token; + `CatchAsync` over a `ValueTask` and `TraverseAsync` on `Option` and `Patch`. ## 2.x, with a spike first diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md index 14ec425..1679edb 100644 --- a/docs/migrating-to-2.md +++ b/docs/migrating-to-2.md @@ -141,7 +141,9 @@ the `IEnumerable` overloads: every row was read and the condition ran in memo to `IQueryable` overloads that hand the condition to the provider, so the answer is the same and one row is read. A condition the provider cannot translate now throws where it used to run in memory; say so with `AsEnumerable()`. `LastOrNone` and `LastOrError` on a query are compile errors -whose message says what to write instead. +whose message says what to write instead. A lambda written at the call is an expression and runs in +the query; a `Func` variable or a method group still binds to the sequence's overload and +reads every row, as in 1.5, so hold an `Expression>` instead. ```csharp Option large = orders.FirstOrNone(o => o.Total > 100); // 2.0: WHERE and LIMIT 1 in the query; 1.5 read every row @@ -174,7 +176,7 @@ new member is an instance member, nothing is ambiguous and yours is silently no | `Result` | `Catch`, `CatchAsync` | 2.0 also adds types to the `CodoMetis.TypeKit` namespace: `Patch` and `Patch`, `PatchState`, -`PatchJsonConverterFactory`, `Fault` and `Fault`, `TextFault`, `StringTextExtensions`, +`PatchJsonConverterFactory`, `SequenceOptionExtensions`, `Fault` and `Fault`, `TextFault`, `StringTextExtensions`, `AbstractAttribute`, `SealedAttribute`, `RecordAttribute`, `ConcreteAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; and `IClosedSet`, `IFaultSet`, `IFault` and `FaultKind` to `CodoMetis.TypeKit.ClosedSets`. A type of yours with one of these names, imported beside the From 74f77d4fb27dcee27a6111c769914eefbb2c2ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 07:51:30 +0200 Subject: [PATCH 138/147] build: the test projects reference System.Security.Cryptography.Pkcs 9.0.21, past GHSA-846f-f764-76mx and GHSA-2fmv-rv2c-59x7 The analyzer-testing packages bring 9.0.6 through NuGet.Packaging, and the SQL Server provider 9.0.11 through Microsoft.Data.SqlClient. No shipping graph has it. Co-Authored-By: Claude Opus 5.5 --- Directory.Packages.props | 7 +++++++ .../CodoMetis.TypeKit.Analyzers.Tests.csproj | 2 ++ .../CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj | 2 ++ 3 files changed, 11 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index a4ebdf7..0cf8548 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -64,6 +64,13 @@ + + diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj index 3dddb16..c6103ac 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj @@ -18,6 +18,8 @@ + + diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj index 5d9623a..7782386 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj @@ -17,6 +17,8 @@ + + From df4e80681e89cb17bf16384f6c20a34ef9363e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 07:51:34 +0200 Subject: [PATCH 139/147] fix: the CMTK0011 code fix is withheld where the added constructor's implicit base() binds to nothing A base with only constructors that take arguments (CS7036), one the type cannot access (CS0122), or two that base() could each be (CS0121): the fix wrote a constructor that did not compile. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- ...ParameterlessConstructorCodeFixProvider.cs | 38 +++++-- src/CodoMetis.TypeKit.Analyzers/README.md | 4 + ...edParameterlessConstructorAnalyzerTests.cs | 104 ++++++++++++++++++ 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index 8bdcdcb..7b8f39f 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -41,7 +41,7 @@ exist. | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | | CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | -| CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor in any of its parts gets none (CS8862). It writes its own trivia and asks no formatter, which laid out the whole type again and wrote the machine's line endings: doc comments, attributes, comments and the members' layout stay, added lines take the members' indentation and the brace's line ending, a one-line type gets the constructor on its line | Error | built 2026-10-13 for 2.0.0 (phase 13) | +| CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor in any of its parts gets none (CS8862), nor, since 2026-10-14, one whose implicit `base()` binds to nothing (CS7036, CS0122, CS0121), which a review found the fix writing. It writes its own trivia and asks no formatter, which laid out the whole type again and wrote the machine's line endings: doc comments, attributes, comments and the members' layout stay, added lines take the members' indentation and the brace's line ending, a one-line type gets the constructor on its line | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | diff --git a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs index ea1ea8e..a39d402 100644 --- a/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs +++ b/src/CodoMetis.TypeKit.Analyzers/CodeFixes/RequiredParameterlessConstructorCodeFixProvider.cs @@ -21,8 +21,10 @@ namespace CodoMetis.TypeKit.Analyzers; /// /// A type with a primary constructor, in any of its parts, gets no fix: every other constructor of /// such a type chains to the primary one (CS8862), and the arguments to chain with are the author's to -/// choose. Nor does a struct, which the rule never reports: its parameterless constructor can only be -/// public. +/// choose. Nor does a type whose base base() does not reach, which the added constructor calls +/// implicitly: a base with only constructors that take arguments (CS7036), one the type cannot access +/// (CS0122), or two that base() could each be (CS0121). Nor does a struct, which the rule never +/// reports: its parameterless constructor can only be public. /// /// /// The fix writes its own trivia and asks no formatter, which would lay out the whole type again: the @@ -76,7 +78,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) diagnostic); } else if (node.FirstAncestorOrSelf() is { } type - && !await HasPrimaryConstructorAsync(context.Document, type, context.CancellationToken).ConfigureAwait(false)) + && await CanAddConstructorAsync(context.Document, type, context.CancellationToken).ConfigureAwait(false)) { context.RegisterCodeFix( CodeAction.Create( @@ -89,18 +91,34 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) } /// - /// Whether any part of the type declares a primary constructor, or whether that cannot be told: a - /// fix withheld is better than one that does not compile. + /// Whether a parameterless constructor added to the type compiles, or whether that cannot be told: a + /// fix withheld is better than one that does not compile. It does not where any part of the type + /// declares a primary constructor, nor where its implicit base() binds to nothing. /// - private static async Task HasPrimaryConstructorAsync(Document document, TypeDeclarationSyntax type, CancellationToken cancellationToken) + private static async Task CanAddConstructorAsync(Document document, TypeDeclarationSyntax type, CancellationToken cancellationToken) { - if (type.ParameterList is not null) return true; - if (!type.Modifiers.Any(SyntaxKind.PartialKeyword)) return false; + if (type.ParameterList is not null) return false; var model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - if (model?.GetDeclaredSymbol(type, cancellationToken) is not INamedTypeSymbol symbol) return true; + if (model?.GetDeclaredSymbol(type, cancellationToken) is not INamedTypeSymbol symbol) return false; - return symbol.DeclaringSyntaxReferences.Any(reference => reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax { ParameterList: not null }); + return !symbol.DeclaringSyntaxReferences.Any(reference => reference.GetSyntax(cancellationToken) is TypeDeclarationSyntax { ParameterList: not null }) + && BaseCallBinds(symbol, model.Compilation); + } + + /// + /// Whether base() in a constructor of binds: to the base's one + /// accessible parameterless constructor, or, without one, to its one accessible constructor whose + /// parameters are all optional or a params array. Two of the second kind are ambiguous. + /// + private static bool BaseCallBinds(INamedTypeSymbol type, Compilation compilation) + { + if (type.BaseType is not { SpecialType: not SpecialType.System_Object } baseType) return true; + + var reachable = baseType.InstanceConstructors.Where(constructor => compilation.IsSymbolAccessibleWithin(constructor, type)).ToList(); + if (reachable.Any(constructor => constructor.Parameters.IsEmpty)) return true; + + return reachable.Count(constructor => constructor.Parameters.All(parameter => parameter.IsOptional || parameter.IsParams)) == 1; } /// diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index b95b012..8fa680c 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -421,6 +421,10 @@ members are and end as the file's lines do, and a type written on one line gets that line. The requirement reaches a type through any chain of base classes and interfaces, and the attribute is read from metadata, so the marked type can live in another package. +Nor does a type whose base `base()` cannot reach, which the added constructor calls implicitly: a +base with only constructors that take arguments, one the type cannot access, or two that `base()` +could each be. Which constructor to chain to is the author's choice. + A struct or record struct is never reported. It always has a public parameterless constructor, the implicit one or one it declares, and C# allows it no other (CS8958): it satisfies `Public` as it is, and no other accessibility can be asked of it. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs index e31b4d9..c6ee004 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/RequiredParameterlessConstructorAnalyzerTests.cs @@ -493,6 +493,110 @@ public Task A_partial_type_whose_other_part_has_a_primary_constructor_gets_no_fi .RunAsync(TestContext.Current.CancellationToken); } + /// + /// The constructor the fix adds calls base() implicitly, so it is withheld where that call + /// does not bind: a base with only a constructor that takes arguments (CS7036), one whose + /// parameterless constructor the type cannot reach (CS0122), and one where base() could be + /// either of two constructors (CS0121). + /// + [Theory] + [InlineData("protected Base(int id) { }")] + [InlineData("private Base() { } protected Base(int id) { }")] + [InlineData("protected Base(int id = 0) { } protected Base(string name = \"\") { }")] + public Task A_type_whose_base_has_no_constructor_base_reaches_gets_no_fix(string constructors) + { + var source = Subjects + $$""" + public abstract class Base { {{constructors}} } + + public sealed class {|#0:Orders|} : Base, Subjects.IEndpoint + { + public Orders(int port) : base(port) { } + } + """; + + return new CodeFixTest(source, source) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + FixedState = { ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") } }, + } + .RunAsync(TestContext.Current.CancellationToken); + } + + /// + /// Where base() binds, the fix is offered: to a parameterless constructor beside others, to one + /// whose parameters are all optional or a params array. + /// The fixed code compiles, or the test fails. + /// + [Theory] + [InlineData("protected Base() { } protected Base(int id) { }")] + [InlineData("protected Base(int id = 0) { }")] + [InlineData("protected Base(params int[] ids) { }")] + public Task A_type_whose_base_has_a_constructor_base_reaches_gets_the_fix(string constructors) => + new CodeFixTest( + Subjects + $$""" + public abstract class Base { {{constructors}} } + + public sealed class {|#0:Orders|} : Base, Subjects.IEndpoint + { + public Orders(int port) { } + } + """, + Subjects + $$""" + public abstract class Base { {{constructors}} } + + public sealed class Orders : Base, Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) { } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// A private parameterless constructor is reached by a type nested in its own. + [Fact] + public Task A_nested_type_reaching_its_container_s_private_constructor_gets_the_fix() => + new CodeFixTest( + Subjects + """ + public class Base + { + private Base() { } + + public Base(int id) { } + + public sealed class {|#0:Orders|} : Base, Subjects.IEndpoint + { + public Orders(int port) : base(port) { } + } + } + """, + Subjects + """ + public class Base + { + private Base() { } + + public Base(int id) { } + + public sealed class Orders : Base, Subjects.IEndpoint + { + public Orders() + { + } + + public Orders(int port) : base(port) { } + } + } + """) + { + ExpectedDiagnostics = { Cmtk0011("Orders", "declares no parameterless constructor", "IEndpoint", "public") }, + } + .RunAsync(TestContext.Current.CancellationToken); + /// /// The fix adds a constructor and touches nothing else: the type's doc comment, its attributes and a /// comment beside one stay where they are, and the members keep their layout. From 0bfa8780720034f1bf855dd0525d3fca240973c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 07:51:39 +0200 Subject: [PATCH 140/147] feat: CMTK0013 refuses a closed set's member written outside its declaration The generators name each member and fill All after the static field initializers and before the static constructor's body, so Open = Closed in that body made Closed.Match take the open branch and Parse("Open") write "Closed"; in another field's initializer it gave one instance two names. Every write is reported: an assignment, a deconstruction, an increment, a ref or out argument and a writable ref taken to the member, each also through a ref conditional. A ref readonly local or field, declared or reassigned, only reads and is silent. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- design/analyzers.md | 1 + design/closed-sets.md | 6 +- design/discovery.md | 2 +- design/roadmap.md | 2 +- docs/closed-sets.md | 1 + docs/comparison.md | 2 +- docs/migrating-to-2.md | 4 +- .../AnalyzerReleases.Unshipped.md | 1 + .../ClosedSetMemberAssignedAnalyzer.cs | 103 ++++++++++ .../DiagnosticIds.cs | 2 + src/CodoMetis.TypeKit.Analyzers/README.md | 27 ++- src/CodoMetis.TypeKit.Generators/README.md | 7 +- .../ClosedSets/IClosedSet.cs | 3 +- src/CodoMetis.TypeKit/README.md | 2 + .../ClosedSetMemberAssignedAnalyzerTests.cs | 178 ++++++++++++++++++ .../BuildOutcomeTests.cs | 20 +- .../ClosedSetMemberAssignedSamples.cs | 33 ++++ 19 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 src/CodoMetis.TypeKit.Analyzers/ClosedSetMemberAssignedAnalyzer.cs create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/ClosedSetMemberAssignedAnalyzerTests.cs create mode 100644 test/CodoMetis.TypeKit.Samples/Analyzers/ClosedSetMemberAssignedSamples.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ae4dc..91ecfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Still `net10.0` and Metalama 2026.1. What builds or runs differently, and what t - **Text guards**: `AsText()` and `AsIdentifier()` trim, compose to NFC (`AsText`) and refuse invisible characters. - **Type constraints**: `[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]` (CMTK0010) and `[RequireParameterlessConstructor]` (CMTK0011, with a code fix). - **CMTK0012**: a closed set's `Match` called by position, with a code fix. +- **CMTK0013**: a closed set's member written outside its declaration, after it was named. - **`Option`**: async steps and `Task>` continuations, `SingleOrNone`, `ElementAtOrNone`, `Choose`, `Transpose`, `Traverse`; EF Core's `FirstOrNoneAsync` and `SingleOrNoneAsync`. - **`TraverseAsync`** on a sequence: one at a time, cancellable. - **Docs**: topic pages under `docs/`, every code block compiled. diff --git a/README.md b/README.md index 6dd5464..5d8829b 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Beside the generated types, the base package holds: | Package | Role | Metalama | |---|---|---| | [CodoMetis.TypeKit](src/CodoMetis.TypeKit/README.md) | `Option`, `Result`, `Result`, the value-object contracts, the text guards `AsText()` and `AsIdentifier()`, and the analyzers that guard them | no | -| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0012: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values; no type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`; no type without the constructor its host will look for; no closed-set `Match` bound by position. In Razor components too. Arrives with the base package | no | +| [CodoMetis.TypeKit.Analyzers](src/CodoMetis.TypeKit.Analyzers/README.md) | CMTK0001–CMTK0013: no `default` of a value object, an `Option` or a `Result`, written in source or handed out by `FirstOrDefault` and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values; no type argument that breaks what its parameter declares with `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`; no type without the constructor its host will look for; no closed-set `Match` bound by position; no closed-set member written after it was named. In Razor components too. Arrives with the base package | no | | [CodoMetis.TypeKit.Generators](src/CodoMetis.TypeKit.Generators/README.md) | The compile-time generation, in the project that declares value objects | yes | | [CodoMetis.TypeKit.EntityFrameworkCore](src/CodoMetis.TypeKit.EntityFrameworkCore/README.md) | Value objects as columns with nothing registered per type, and `.Value` in LINQ | no | | [CodoMetis.TypeKit.AspNetCore](src/CodoMetis.TypeKit.AspNetCore/README.md) | Value objects in the OpenAPI document, with the schema of the type they wrap; a `Result` as the response, a fault as problem details | no | diff --git a/design/analyzers.md b/design/analyzers.md index 7b8f39f..6c9f327 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -43,6 +43,7 @@ exist. | CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | | CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor in any of its parts gets none (CS8862), nor, since 2026-10-14, one whose implicit `base()` binds to nothing (CS7036, CS0122, CS0121), which a review found the fix writing. It writes its own trivia and asks no formatter, which laid out the whole type again and wrote the machine's line endings: doc comments, attributes, comments and the members' layout stay, added lines take the members' indentation and the brace's line ending, a one-line type gets the constructor on its line | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | +| CMTK0013 | A closed set's member written outside its declaration: an assignment, compound or `??=`, a deconstruction, an increment, a `ref` or `out` argument, or a writable `ref` taken to it, each also through a `ref` conditional (`(flag ? ref Open : ref Closed) = x`, found by the review of the rule itself). A `ref readonly` local or field reassigned to a member reads it and is silent (a false report found by the weaving review, 2026-10-14). C# allows such a write only in the set's static constructor and static field initializers, which run before (initializers) or after (the constructor's body) the generated initializer that names the members and fills `All`; either way an instance answered to another member's name, `Match` took another's branch, and `All` disagreed with `Parse`. An analyzer, not the aspect: Metalama 2026.1's code model gives no operations of a constructor body, and a textual scan would miss a deconstruction or a `ref` | Error | built 2026-10-14 for 2.0.0, a major, after a review reproduced the swap against the packed generators | | — | Code fixes for the aspect's shape diagnostics CMTK1000–1002 (missing `partial`/`record`/`readonly`) | — | proposed. The aspect keeps its own error as a backstop | **Rules that read calls see the source before weaving (measured 2026-09-28).** Metalama runs diff --git a/design/closed-sets.md b/design/closed-sets.md index bbb148c..fc56e88 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -21,7 +21,11 @@ The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). (`InitializerKind.BeforeTypeConstructor`) assigns each member's name and builds `All`, so a member needs no `nameof`, and the parts of a set split across files are all initialised when it runs. The cost is a window: a static field initializer of the set itself runs before it, and anything - generated that it reads there is refused rather than answered from a half-built set. + generated that it reads there is refused rather than answered from a half-built set. A member + written in the static constructor's body would hold an instance already named as another, and one + written in a field initializer would give one instance two names, all of which compiles: the + analyzer refuses every write to a member outside its declaration (CMTK0013, decided 2026-10-14 + before the tag, after a review found `Open = Closed` turning `Closed.Match` to the open branch). - **`All` is a wrapper, never the array.** Cast to `IList` or `T[]`, the array was writable, and a member replaced there was listed while `Parse` answered the original. - **Ordinal unless asked** ([decision 51](adr/0051-closed-set-parses-ignoring-case-only.md)): JSON, diff --git a/design/discovery.md b/design/discovery.md index 226fbb9..f36f9c5 100644 --- a/design/discovery.md +++ b/design/discovery.md @@ -11,7 +11,7 @@ passes in tests and breaks for the first consumer who renames something. | EF Core | EF asks the type-mapping plugin per CLR type, and it answers for any type carrying `GeneratedValueObjectAttribute` ([EF Core](ef-core.md)). There is no type scan and no assembly filter | | OpenAPI | The transformer reads `GeneratedValueObjectAttribute<,>` on the schema's `JsonTypeInfo.Type`, its element type, or a parameter's type or model metadata ([OpenAPI](openapi.md)). There is no referenced-assembly walk | | Generators, closed sets | The fabric selects a class that implements `IClosedSet`, directly or through `IFaultSet` or another interface, comparing each implemented interface's definition with the symbol of `IClosedSet<>` ([Closed sets](closed-sets.md)). A member is a public static readonly field of the set's own type, found by its type, never by a name | -| Analyzer, closed-set rules | `IClosedSet`1` is resolved with `GetTypesByMetadataName` beside the value-object markers, and a set is a type whose interfaces' `OriginalDefinition` is one of those symbols (CMTK0002, CMTK0012) | +| Analyzer, closed-set rules | `IClosedSet`1` is resolved with `GetTypesByMetadataName` beside the value-object markers, and a set is a type whose interfaces' `OriginalDefinition` is one of those symbols (CMTK0002, CMTK0012, CMTK0013) | | Run time, closed sets | EF Core's type-mapping plugin, the OpenAPI schema transformer and the Serilog policy read `GeneratedClosedSetAttribute` on the type, whose type argument is constrained to `IClosedSet`; `Accept` hands the set to generic code as a type argument, so there is no `GetInterfaces()` and no `MakeGenericType` | Run-time code that holds only a `Type` reads the attribute rather than calling `GetInterfaces()` diff --git a/design/roadmap.md b/design/roadmap.md index 1ecda75..18a5bc3 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -7,7 +7,7 @@ item names the decision that scoped it. - `2.0.0-preview.1` to nuget.org, on hold since 2026-10-13 until the user says so, then the consumer smoke test against it ([decision 42](adr/0042-2-0-0-next-release-bundles.md)). -- The release: date the changelog's section, move the unshipped analyzer rows (CMTK0010 to CMTK0012 +- The release: date the changelog's section, move the unshipped analyzer rows (CMTK0010 to CMTK0013 new, CMTK0003 changed) to `AnalyzerReleases.Shipped.md` under the release, merge so the help links' headings exist on `main`, and tag `v2.0.0`. The release workflow and `ChangelogTests` check each. diff --git a/docs/closed-sets.md b/docs/closed-sets.md index 2a50c49..7164607 100644 --- a/docs/closed-sets.md +++ b/docs/closed-sets.md @@ -177,6 +177,7 @@ A declaration they cannot complete fails the build, naming it; the generators' | is a record: `with` would copy a member into an instance that is none | CMTK1020 | | has a member named as a generated member, or two whose names differ only in case (`Open`, `open`), which collide in `Match`'s parameters and in `TryParse(name, ignoreCase: true)`, or two whose `Match` parameters would have one name (a Kelvin sign and a `K`), or is itself named as a generated member (`Name`, `Match`): a member cannot have the name of its type | CMTK1021 | | writes a generated member by hand, `ToString()` apart | CMTK1011 | +| writes a member outside its declaration, in its static constructor or another field's initializer: `Open = Closed` would leave `Open` holding the instance named `Closed` | CMTK0013 | | is compiled without CodoMetis.TypeKit.Generators | CMTK0002 | The set's own code can still call its private constructor beside its members, and such an instance diff --git a/docs/comparison.md b/docs/comparison.md index 8dbc5f0..672ac71 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -59,7 +59,7 @@ other side, and the detail. | JSON | System.Text.Json, the name, nothing registered | A separate package per serializer, a converter attribute per type; the name or the value | Generated into your assembly; the value, as an `enum` writes | A separate package per serializer; the key | | EF Core | `UseTypeKit()`, a text column of the name, nothing per set | A separate package, a convention or per model; the value | An opt-in generated converter, registered per property; the value | A separate package per EF version, `UseThinktectureValueConverters()`; the key | | OpenAPI | `AddTypeKit()`, a string enum of the names | None built in | None built in | A Swashbuckle package | -| Analyzers | CMTK1013–1021 refuse a declaration that cannot be generated, CMTK0012 an argument by position | None | INTELLENUM001–030 | TTRESG rules, with refactorings | +| Analyzers | CMTK1013–1021 refuse a declaration that cannot be generated, CMTK0012 an argument by position, CMTK0013 a member written after it was named | None | INTELLENUM001–030 | TTRESG rules, with refactorings | | A wire name apart from the identifier | No: the name is the field's | Yes, the constructor's name | Yes, `new("STD", 1)` | Yes, the key | | Targets | `net10.0` | `netstandard2.0`, `net6.0` to `net8.0` | `netstandard2.0` | `net8.0`, `net9.0` | | Generated by | Metalama, in the declaring project | Nothing: a run-time base class | A Roslyn source generator | Roslyn source generators | diff --git a/docs/migrating-to-2.md b/docs/migrating-to-2.md index 1679edb..c8b6786 100644 --- a/docs/migrating-to-2.md +++ b/docs/migrating-to-2.md @@ -53,8 +53,8 @@ a large codebase in steps, lower the rule for a while: dotnet_diagnostic.CMTK0003.severity = warning ``` -CMTK0010, CMTK0011 and CMTK0012 are new errors too, but each reports only on what 2.0 adds: the -type-parameter attributes, `[RequireParameterlessConstructor]` and a closed set's `Match`. No code +CMTK0010 to CMTK0013 are new errors too, but each reports only on what 2.0 adds: the +type-parameter attributes, `[RequireParameterlessConstructor]` and closed sets. No code that built against 1.5 meets them. ## Value objects that are build errors now diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md index 5c6d382..b24b548 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md @@ -8,6 +8,7 @@ Rule ID | Category | Severity | Notes CMTK0010 | Usage | Error | TypeArgumentConstraintAnalyzer, a type argument that breaks [Abstract], [Sealed], [Record] or [Concrete] on its parameter CMTK0011 | Usage | Error | RequiredParameterlessConstructorAnalyzer, a concrete type under [RequireParameterlessConstructor] without the constructor it requires CMTK0012 | Usage | Error | PositionalMatchArgumentAnalyzer, a closed set's Match given an argument by position +CMTK0013 | Usage | Error | ClosedSetMemberAssignedAnalyzer, a closed set's member written outside its declaration ### Changed Rules diff --git a/src/CodoMetis.TypeKit.Analyzers/ClosedSetMemberAssignedAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/ClosedSetMemberAssignedAnalyzer.cs new file mode 100644 index 0000000..f4c77e2 --- /dev/null +++ b/src/CodoMetis.TypeKit.Analyzers/ClosedSetMemberAssignedAnalyzer.cs @@ -0,0 +1,103 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace CodoMetis.TypeKit.Analyzers; + +/// +/// CMTK0013: a closed set's member written anywhere but its own declaration. +/// +/// +/// +/// The generators give each member its name and fill All after the static field initializers +/// and before the static constructor's body. A member assigned in that body, Open = Closed;, +/// keeps the field pointing at another member: Closed.Match takes the open branch, +/// Parse("Open") answers an instance written as "Closed", and All still lists the +/// instance the field no longer holds. One assigned in another static field's initializer, before the +/// names, gives one instance two. All of it compiles, and C# allows such a write nowhere else: a +/// static readonly field is written only by its type's static initializers and constructor. +/// +/// +/// A write is an assignment, compound or ??= included, a deconstruction into the field, an +/// increment, a ref or out argument, and a writable ref taken to it, through which it +/// can be written later (a ref readonly one, declared or reassigned, only reads); each also where the field is a branch of a ref conditional, +/// (flag ? ref Open : ref Closed) = Closed. The field's own initializer is no reference to it, and CMTK1019 holds it to new. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ClosedSetMemberAssignedAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticIds.ClosedSetMemberAssigned, + title: "A closed set's member written outside its declaration", + messageFormat: "'{0}.{1}' is written outside its declaration, after the generators named the instance it held and listed it in All: Match, Parse and the JSON would answer for another member, or none. Create each member with new where it is declared, and never write it again.", + category: "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A closed set's members are named and listed once, after the static field initializers and before the static constructor's body. A member written in either then holds an instance whose name and place in All are another's.", + helpLinkUri: DiagnosticIds.HelpLink(DiagnosticIds.ClosedSetMemberAssigned) + ); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCode.AnalysisFlags); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(start => + { + if (TypeKitSymbols.Resolve(start.Compilation) is not { } symbols) return; + + start.RegisterOperationAction(operation => Analyze(operation, symbols), OperationKind.FieldReference); + }); + } + + private static void Analyze(OperationAnalysisContext context, TypeKitSymbols symbols) + { + var reference = (IFieldReferenceOperation)context.Operation; + var field = reference.Field; + + if (field is not { IsStatic: true, IsReadOnly: true } || !SymbolEqualityComparer.Default.Equals(field.Type, field.ContainingType)) return; + if (!IsWrite(reference)) return; + if (field.ContainingType.TypeKind != TypeKind.Class || symbols.FindClosedSetMarker(field.ContainingType) is null) return; + + context.Report(Diagnostic.Create(Rule, reference.Syntax.GetLocation(), field.ContainingType.Name, field.Name)); + } + + /// Whether the reference writes the field, or hands out a ref through which it can be written. + private static bool IsWrite(IOperation reference) + { + // (Open, Closed) = (Closed, Open), and flag ? ref Open : ref Closed: the field is an element of a + // tuple, or a branch of a ref conditional, possibly nested, and that is what is written. + var target = reference; + while (target.Parent is ITupleOperation || target.Parent is IConditionalOperation { IsRef: true } conditional && conditional.Condition != target) + target = target.Parent; + + return target.Parent switch + { + IDeconstructionAssignmentOperation deconstruction when deconstruction.Target == target => true, + IAssignmentOperation assignment when assignment.Target == target => true, + ISimpleAssignmentOperation { IsRef: true } assignment when assignment.Value == target => IsWritableRef(assignment.Target), + IIncrementOrDecrementOperation => true, + IArgumentOperation { Parameter.RefKind: RefKind.Ref or RefKind.Out } => true, + IVariableInitializerOperation { Parent: IVariableDeclaratorOperation { Symbol.RefKind: RefKind.Ref } } => true, + _ => false, + }; + } + + /// + /// Whether a ref reassigned to the field can write it: a ref readonly local, parameter or + /// field only reads, alias = ref Open as much as its declaration. + /// + private static bool IsWritableRef(IOperation target) => target switch + { + ILocalReferenceOperation local => local.Local.RefKind == RefKind.Ref, + IParameterReferenceOperation parameter => parameter.Parameter.RefKind is RefKind.Ref or RefKind.Out, + IFieldReferenceOperation field => field.Field.RefKind == RefKind.Ref, + _ => true, + }; +} diff --git a/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs b/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs index 5e88edb..a019b3c 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DiagnosticIds.cs @@ -30,6 +30,8 @@ internal static class DiagnosticIds public const string PositionalMatchArgument = "CMTK0012"; + public const string ClosedSetMemberAssigned = "CMTK0013"; + /// /// The rule's section of the analyzer README on GitHub, which an IDE opens from the diagnostic. /// The README's ## CMTK000N headings give the anchors (AnalyzerHelpLinkTests). diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 8fa680c..a0f0235 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -24,6 +24,7 @@ appears to: an instance that passed no factory, or a value object that was never | CMTK0010 | Error | A type argument that breaks a constraint its parameter declares by attribute, `[Abstract]`, `[Sealed]`, `[Record]` or `[Concrete]`, at any closed use: a base type, a member, a parameter, a local, a `typeof`, an attribute, a call or a method group, written or inferred, and a member of a generic extension block, whose arguments the receiver gives. A type parameter forwarded without the attribute is a breach too, and a method implementing an interface's method, or overriding one, carries the constraint of what it implements, a static one and one a base class declares included. | | CMTK0011 | Error | A concrete class that derives from or implements a type marked `[RequireParameterlessConstructor]`, directly or through another, without a parameterless constructor of the accessibility it asks for. A struct is never reported: its parameterless constructor is public and can be nothing else. The code fix writes or changes the constructor. | | CMTK0012 | Error | A closed set's `Match` given an argument by position, which binds to whichever member is declared at that place, so reordering the members swaps the branches. The code fix names every argument. | +| CMTK0013 | Error | A closed set's member written outside its declaration: in the static constructor, after the generators named the instance it held, or in another static field's initializer, before. | The ids are public contract and never change meaning. The generators' own build errors, CMTK1000 to CMTK1022, come from `CodoMetis.TypeKit.Generators` and are listed in its README. @@ -446,6 +447,28 @@ meaning. A parameter is the member's name with its first letter lower-cased, and escaped: a member `New` is `@new:`. An overload the set declares itself, such as `Match(Func)`, is its own and is not reported. +## CMTK0013 + +The generators give each member of a closed set its name, and fill `All`, after the static field +initializers and before the static constructor's body. A member written in that body keeps the +field pointing at an instance already named as another member; one written in another field's +initializer gives one instance two names. Both compile, and C# allows the write nowhere else. + +```csharp +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Closed = new(); + + static OrderStatus() { Open = Closed; } // CMTK0013: Open now holds the instance named "Closed" +} +``` + +There, `OrderStatus.Closed.Match(open: …, closed: …)` takes the `open` branch, `Parse("Open")` is +written back as `"Closed"`, and `All[0]` is not `Parse("Open")`. An assignment, `??=`, a +deconstruction, a `ref` or `out` argument and a `ref` taken to the member are each reported. A +static constructor that only reads the members, to build a lookup, is silent. + ## Configuration Severity follows the usual `.editorconfig` mechanism, for instance @@ -456,8 +479,8 @@ there too, put the line in a `.globalconfig` file, which applies to every file o The errors are the rules where the code is wrong whatever surrounds it: a `default` written out (CMTK0001), a value object nothing generates (CMTK0002), validation bypassed (CMTK0004), a type argument that breaks what its parameter declares (CMTK0010), a constructor a host will look for -and not find (CMTK0011), and branches bound to members by their order (CMTK0012). So is a result -dropped without saying so (CMTK0003): a drop that nothing depends on is correct, and `_` says it in +and not find (CMTK0011), branches bound to members by their order (CMTK0012), and a member that +answers to another's name (CMTK0013). So is a result dropped without saying so (CMTK0003): a drop that nothing depends on is correct, and `_` says it in the code, where every reader sees it. These fail the build because a warning on correct code is rare for them and a missed one is a defect at run time. The others are warnings, or for CMTK0007 Info, because correct code can look the same and has nothing diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index fda16c0..4921952 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -249,8 +249,9 @@ satellite describes it as a string enum of the names, with nothing registered pe members are faults declares `IFaultSet`, a `FaultKind` and an optional description per member, and the ASP.NET Core satellite answers it by kind. -Each member is initialized with `new` where it is declared (CMTK1019), and the constructors are -private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated +Each member is initialized with `new` where it is declared (CMTK1019), and never written again +(CMTK0013, the analyzer's: a member assigned in the static constructor would hold an instance named +and listed as another), and the constructors are private (CMTK1016); a record is refused (CMTK1020). A member may not take the name of a generated member, nor differ from another's only in case (`Open` and `open`), since `TryParse(name, ignoreCase: true)` could not tell them apart (CMTK1021). A `ToString()` written by hand is kept, and the name is still what JSON, parsing and the column use. An overload of a generated @@ -394,7 +395,7 @@ A declaration that cannot be generated is an error, so no type is left half-gene | CMTK1021 | A closed set's member named as a member the generators introduce (`Name`, `QualifiedName`, `All`, `Match`, `Parse`, `TryParse`, `ToString`, the JSON or type converter), a set itself named as one of them (a member cannot have the name of its type), or a member whose name differs from another member's only in case: they collide in `TryParse(name, ignoreCase: true)`, and in `Match`'s parameters where the first letter is the difference; or two members whose `Match` parameters would have one name, which lower-casing the first letter gives a member starting with the Kelvin sign (U+212A) and one starting with `K`. | | CMTK1022 | An abstract type with a value-object marker: it would get no member, and a value held as it, of a type derived from it, would have no `Value` and be written as `{}`. Declare the marker on the sealed type. An abstract closed set is CMTK1014. | -The analyzers that come with the base package add CMTK0001 to CMTK0012; see +The analyzers that come with the base package add CMTK0001 to CMTK0013; see [CodoMetis.TypeKit.Analyzers](https://www.nuget.org/packages/CodoMetis.TypeKit.Analyzers). ## Reading the generated code diff --git a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs index c777343..5f32333 100644 --- a/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs +++ b/src/CodoMetis.TypeKit/ClosedSets/IClosedSet.cs @@ -31,7 +31,8 @@ namespace CodoMetis.TypeKit.ClosedSets; /// only private constructors (CMTK1016), no other public static member of its own type (CMTK1017), /// and not a record (CMTK1020), since every one of those would make an instance the generators cannot /// see as a member. Each member is initialized with new where it is declared, and with nothing -/// else (CMTK1019): a member initialized with another member would give that instance two names. A +/// else (CMTK1019), and never written again (CMTK0013): a member initialized or assigned with another +/// member would give that instance two names, or answer to another's. A /// member takes no name the generators use, nor another's Match parameter (CMTK1021). A /// ToString() written by hand is kept, as is an overload of a generated method with another /// signature; any other member the generators introduce, written by hand, is CMTK1011. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 5964146..f80b929 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -500,6 +500,8 @@ They arrive with this package, so whoever can see the interfaces gets the guard: The code fix writes it. - **CMTK0012**: a closed set's `Match` given an argument by position, where reordering two members would swap their branches and compile. The code fix names every argument. +- **CMTK0013**: a closed set's member written outside its declaration, in the static constructor or + another field's initializer, so it holds an instance named and listed as another member. The rules also run in Razor components (`.razor`, `.cshtml`). diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/ClosedSetMemberAssignedAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/ClosedSetMemberAssignedAnalyzerTests.cs new file mode 100644 index 0000000..8ae9e46 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/ClosedSetMemberAssignedAnalyzerTests.cs @@ -0,0 +1,178 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0013: a closed set's member written outside its declaration, after the generators named the +/// instance it held, or before, giving one instance two names. +/// +/// +/// The verifier never weaves, so the set declares by hand what the generators would. BuildOutcomeTests +/// holds the rule to a real woven build. +/// +public sealed class ClosedSetMemberAssignedAnalyzerTests +{ + private static string Set(string body) => + $$""" + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Threading; + using CodoMetis.TypeKit.ClosedSets; + + public sealed partial class Status : IClosedSet + { + public static readonly Status Open = new(); + public static readonly Status Closed = new(); + + private Status() { } + + public string Name => ""; + public string QualifiedName => ""; + public static IReadOnlyList All => [Open, Closed]; + public static bool TryParse([NotNullWhen(true)] string? name, [MaybeNullWhen(false)] out Status member) { member = null; return false; } + + private static void Make(out Status member) => member = new(); + private static void Read(in Status member) { } + private static void Take(Status member) { } + + private ref struct Holder + { + public ref readonly Status Member; + public ref Status Writable; + } + + {{body}} + } + """; + + private static AnalyzerTest Test(string source, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(source); + test.ExpectedDiagnostics.AddRange(expected); + return test; + } + + private static DiagnosticResult Cmtk0013(int location, string member) => + new DiagnosticResult("CMTK0013", DiagnosticSeverity.Error).WithLocation(location).WithArguments("Status", member); + + [Fact] + public void The_descriptor_is_the_published_contract() + { + var rule = new ClosedSetMemberAssignedAnalyzer().SupportedDiagnostics.ShouldHaveSingleItem(); + + rule.Id.ShouldBe("CMTK0013"); + rule.DefaultSeverity.ShouldBe(DiagnosticSeverity.Error); + rule.IsEnabledByDefault.ShouldBeTrue(); + } + + /// The defect as found: Closed.Match took the open branch, and Parse("Open") wrote "Closed". + [Fact] + public Task An_assignment_in_the_static_constructor_is_reported() => + Test(Set(" static Status() { {|#0:Open|} = Closed; }"), Cmtk0013(0, "Open")) + .RunAsync(TestContext.Current.CancellationToken); + + /// Every form of a write C# allows a static readonly field there, each reported at the field. + [Fact] + public Task Every_form_of_a_write_is_reported() => + Test( + Set( + """ + static Status() + { + {|#0:Status.Open|} = Closed; + {|#1:Open|} ??= Closed; + ({|#2:Open|}, ({|#3:Closed|}, _)) = (Closed, (Open, 0)); + Make(out {|#4:Closed|}); + Interlocked.Exchange(ref {|#5:Open|}, Closed); + ref Status alias = ref {|#6:Closed|}; + alias = Open; + alias = ref {|#7:Open|}; + (Open.Name.Length == 0 ? ref {|#8:Open|} : ref {|#9:Closed|}) = Closed; + Make(out (Open.Name.Length == 0 ? ref {|#10:Open|} : ref {|#11:Closed|})); + ref Status either = ref Open.Name.Length == 0 ? ref {|#12:Open|} : ref {|#13:Closed|}; + ref readonly Status read = ref Open.Name.Length == 0 ? ref Open : ref Closed; + var holder = new Holder(); + holder.Writable = ref {|#14:Open|}; + } + """), + Cmtk0013(0, "Open"), Cmtk0013(1, "Open"), Cmtk0013(2, "Open"), Cmtk0013(3, "Closed"), + Cmtk0013(4, "Closed"), Cmtk0013(5, "Open"), Cmtk0013(6, "Closed"), Cmtk0013(7, "Open"), + Cmtk0013(8, "Open"), Cmtk0013(9, "Closed"), Cmtk0013(10, "Open"), Cmtk0013(11, "Closed"), Cmtk0013(12, "Open"), Cmtk0013(13, "Closed"), + Cmtk0013(14, "Open")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A static field's initializer runs before the names are given, so a member assigned there gives + /// the instance two names, the later winning. + /// + [Fact] + public Task An_assignment_in_another_field_s_initializer_is_reported() => + Test(Set(" private static readonly bool Swapped = ({|#0:Open|} = Closed) is not null;"), Cmtk0013(0, "Open")) + .RunAsync(TestContext.Current.CancellationToken); + + /// The static constructor may be in another part of the set. + [Fact] + public Task An_assignment_in_another_part_is_reported() => + new AnalyzerTest(Set("")) + { + TestState = { Sources = { ("Other.cs", "public sealed partial class Status { static Status() { {|#0:Closed|} = Open; } }") } }, + ExpectedDiagnostics = { Cmtk0013(0, "Closed") }, + } + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// Reading the members after their names are given is what a static constructor is for: a lookup, + /// an argument by value or by in, a ref readonly local or field, declared or + /// reassigned, a comparison. + /// + [Fact] + public Task Reads_are_silent() => + Test( + Set( + """ + private static readonly Dictionary Order; + + static Status() + { + Order = new() { [Open] = 0, [Closed] = 1 }; + var first = Open; + Take(Closed); + Read(in Open); + ref readonly Status alias = ref Closed; + alias = ref Open; + alias = ref Open.Name.Length == 0 ? ref Open : ref Closed; + var holder = new Holder(); + holder.Member = ref Closed; + _ = ReferenceEquals(Open, Closed) || Open.Name.Length == 0; + } + """)) + .RunAsync(TestContext.Current.CancellationToken); + + /// The same writes in a class that is no closed set, and to a static field of the set that is of another type, are nobody's concern here. + [Fact] + public Task A_class_that_is_no_closed_set_and_a_field_of_another_type_are_silent() => + Test( + Set( + """ + private static readonly string Label = ""; + + static Status() { Label = "set"; } + } + + public sealed class Plain + { + public static readonly Plain Open = new(); + public static readonly Plain Closed = new(); + + static Plain() { Open = Closed; } + """)) + .RunAsync(TestContext.Current.CancellationToken); + + /// Robustness, not a guard: without CodoMetis.TypeKit there is nothing to look at, and nothing may fail. + [Fact] + public Task A_compilation_without_TypeKit_is_left_alone() => + new AnalyzerTest("public sealed class S { public static readonly S A = new(); static S() { A = new(); } }", referenceTypeKit: false) + .RunAsync(TestContext.Current.CancellationToken); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 8c4d073..e14a448 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -206,6 +206,15 @@ public void A_member_is_never_told_to_be_removed_as_a_hand_written_generated_mem public void CMTK0012_reports_a_call_by_position_with_the_generators_names_in_the_declaring_project() => consumer.Errors.ShouldContain(error => error.Id == "CMTK0012" && error.Message.StartsWith("'SetToMatch.Match' is given an argument by position") && error.Message.Contains("(@new:, open:)"), consumer.Output); + /// + /// A member assigned in the static constructor compiles, and the generators named it before that + /// body ran: Closed.Match took the open branch, and Parse("Open") was written as + /// "Closed". CMTK0013 reports it in the project that declares the set, beside the weaving. + /// + [Fact] + public void CMTK0013_reports_a_member_assigned_in_the_static_constructor() => + consumer.Errors.ShouldContain(error => error.Id == "CMTK0013" && error.Message.StartsWith("'SetReassigned.Open' is written outside its declaration"), consumer.Output); + /// /// Every value object over another one is refused, so a cycle would be refused without this /// reason too, but not told that it loops, or through which types. @@ -460,7 +469,7 @@ 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", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CMTK1022", "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", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); /// /// CS0535 is allowed above only for a refused closed set, whose interface members the generators @@ -576,6 +585,15 @@ public static class MatchCalls public static int Named(SetToMatch set) => set.Match(@new: () => 1, open: () => 2); } + // Generated, and named before the static constructor's body, which then swaps a member: CMTK0013's. + public sealed partial class SetReassigned : IClosedSet + { + public static readonly SetReassigned Open = new(); + public static readonly SetReassigned Closed = new(); + + static SetReassigned() { Open = Closed; } + } + public readonly record struct NotPartial : IValue; public readonly partial struct NotRecord : IValue; diff --git a/test/CodoMetis.TypeKit.Samples/Analyzers/ClosedSetMemberAssignedSamples.cs b/test/CodoMetis.TypeKit.Samples/Analyzers/ClosedSetMemberAssignedSamples.cs new file mode 100644 index 0000000..35c752a --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Analyzers/ClosedSetMemberAssignedSamples.cs @@ -0,0 +1,33 @@ +using CodoMetis.TypeKit.ClosedSets; + +namespace ReadmeSamples.Analyzers.ClosedSetMemberAssigned; + +#pragma warning disable CMTK0013 // what the sample shows the rule reporting +// sample: CodoMetis.TypeKit.Analyzers/cmtk0013 +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Open = new(); + public static readonly OrderStatus Closed = new(); + + static OrderStatus() { Open = Closed; } // CMTK0013: Open now holds the instance named "Closed" +} +// end sample +#pragma warning restore CMTK0013 + +/// What the README says the suppressed rule would have prevented, read from the woven set. +public sealed class ClosedSetMemberAssignedTests +{ + [Fact] + public void Open_holds_the_instance_named_Closed() + { + OrderStatus.Open.Name.ShouldBe("Closed"); + } + + [Fact] + public void Closed_takes_the_open_branch_and_Parse_disagrees_with_All() + { + OrderStatus.Closed.Match(open: () => "open", closed: () => "closed").ShouldBe("open"); + OrderStatus.Parse("Open").ToString().ShouldBe("Closed"); + OrderStatus.All[0].ShouldNotBeSameAs(OrderStatus.Parse("Open")); + } +} From 79aaa97baf8a5edb009aa32243911138e88ccfd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 07:51:39 +0200 Subject: [PATCH 141/147] test: the consumer smoke test holds CMTK0011 and CMTK0013 to the packed packages, in the declaring project on woven code Co-Authored-By: Claude Opus 5.5 --- test/consumer-smoke-test.sh | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index c804b85..41cf41d 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -20,7 +20,8 @@ # layered a domain library references CodoMetis.TypeKit.Generators, and an app reaches everything # only through that library: the app uses the generated members, the transitive fabric # generates a value object the app declares itself, and CMTK0001 fires in the app, in the -# domain library, and in a Razor component of a third project. +# domain library, and in a Razor component of a third project; CMTK0011 and CMTK0013 in +# the domain library, on code Metalama wove. # host a web host with both satellites: EF Core maps the value objects and translates .Value, # and the OpenAPI document describes them. # aot the same ground published with Native AOT (design/native-aot.md): no trim or AOT warning @@ -409,11 +410,33 @@ public static class Guard return id; } } + +// Woven, and named before the static constructor's body, which then swaps a member. +public sealed partial class Swapped : CodoMetis.TypeKit.ClosedSets.IClosedSet +{ + public static readonly Swapped Open = new(); + public static readonly Swapped Closed = new(); + + static Swapped() { Open = Closed; } +} + +[RequireParameterlessConstructor] +public interface IEndpoint; + +public abstract class EndpointBase { protected EndpointBase(int port) { } } + +// No parameterless constructor, and a base that base() does not reach. +public sealed class Orders : EndpointBase, IEndpoint +{ + public Orders(int port) : base(port) { } +} CSHARP run_failing dotnet build --no-incremental echo "$output" | grep -F "Guard.cs(" > "$work/layered.declaring" || true assert_contains "error CMTK0001" "$work/layered.declaring" "CMTK0001 fires in the declaring project for a local typed by a generated member" +assert_contains "error CMTK0013: 'Swapped.Open' is written outside its declaration" "$work/layered.declaring" "CMTK0013 fires in the declaring project for a member assigned in a woven set's static constructor" +assert_contains "error CMTK0011" "$work/layered.declaring" "CMTK0011 fires for a type without the constructor its interface requires" rm Guard.cs # By hand: the razorclasslib template takes no --framework. From cdab8103c8a5b8961a5ba87ac3d8b8563532df64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 08:06:37 +0200 Subject: [PATCH 142/147] fix: CMTK0010 no longer overflows the compiler's stack on a default interface method called through a derived interface There the method is its own implementation, so it was taken for a method it implements and its constraints were asked for again, without end. It is skipped, and each type parameter is asked once. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- .../TypeArgumentConstraintAnalyzer.cs | 20 +++++++++--- .../TypeArgumentConstraintAnalyzerTests.cs | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index 6c9f327..d06799a 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -40,7 +40,7 @@ exist. | CMTK0007 | `FromKnownGood` given a value straight from a parameter of the enclosing method or lambda, or a member or element of one. A value the code produced itself is legitimate and silent; a parameter copied into a local first is not seen. Info, since test theories and helpers that only receive constants are reported too | Info | shipped in 1.0.0 | | CMTK0008 | The wrapped values of two different value objects compared (`==`, `!=`, ordering, `Equals`, `CompareTo`) through `.Value`, `?.Value`, `GetValue()` or `ValueOrNull()`: the typed ids unwrapped into the bug they prevent. Recognised by syntax, since `.Value` of a same-project value object does not bind where analyzers run. Also `string.Equals`, `object.Equals`, a comparer's `Equals`, `string.Compare`/`CompareOrdinal` over two such values (the forms MA0006 rewrites `==` into), and two value objects of different types through `Equals(object)`, which is always false. Also a LINQ join on two such values: a query's `join … on a.Value equals b.Value`, with or without `into`, and `Join`/`GroupJoin`/`LeftJoin`/`RightJoin` of `Enumerable`, `Queryable` and `AsyncEnumerable` whose key selectors are lambdas returning them, resolved by symbol, with the candidates where a key selector does not bind; a composite key is not looked into. A raw value on one side, or an explicit cast, is silent | Warning | shipped in 1.0.0 | | CMTK0009 | A call that returns the `default` of a no-default struct when it finds nothing: `Enumerable`/`Queryable` `FirstOrDefault`, `LastOrDefault`, `SingleOrDefault`, `ElementAtOrDefault`, `DefaultIfEmpty` without a default value, `Nullable.GetValueOrDefault()`, `GetValueOrDefault(dictionary, key)`, `Activator.CreateInstance`, `RuntimeHelpers.GetUninitializedObject`, and `Option.OrDefault()`. The message names `FirstOrNone`, `GetValueOrNone` or `Or`. Warning, since a sequence checked for emptiness first is correct. Also `ImmutableArray`'s own, `Find`/`FindLast` of `List`, `Array` and `ImmutableList`, `ImmutableDictionary.GetValueOrDefault` and the immutable builders', and the async `FirstOrDefaultAsync`, `LastOrDefaultAsync`, `SingleOrDefaultAsync` and `ElementAtOrDefaultAsync` of `System.Linq.AsyncEnumerable` and EF Core, whose type is the method's type argument; EF Core's only where the project references it | Warning | shipped in 1.0.0 | -| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | +| CMTK0010 | A type argument that breaks a constraint attribute on its parameter (`[Abstract]`, `[Sealed]`, `[Record]`, `[Concrete]`), at any closed use: a base type, an implemented interface, a member type, a local, a `typeof`, a call with its type arguments written (at the argument) or inferred (at the call), a method group whose arguments the delegate infers (at the group). A type parameter forwarded without the same attribute is a breach too, unless its C# constraints leave it no breaking argument: `struct`/`unmanaged` or a bound proving `[Sealed]` proves `[Sealed]`; `new()` or whatever proves `[Sealed]` proves `[Concrete]`; a bound that is or proves a record proves `[Record]` (CS8865); nothing but the attribute proves `[Abstract]`. `[Concrete]` accepts an enum, a delegate and `dynamic`, as `new()` and `struct` do for an enum. A default interface method reached through a derived interface carries its own constraint once: it was its own implementation there, and the rule recursed until the compiler's stack overflowed (an independent review, 2026-10-14). Both settled 2026-10-13 before the tag, measured as false reports (an enum, a delegate, `dynamic`; six forwarding forms) with no miss among their neighbours. The open generic in its own body and an unbound `typeof` are silent. Reflection is out of reach | Error | built 2026-10-13 for 2.0.0 (phase 13), a major, so Error is within [decision 34](adr/0034-errors-fixed-within-major-version-warnings.md) | | CMTK0011 | A concrete class implementing or deriving from a type marked `[RequireParameterlessConstructor]`, directly or through another type, without a parameterless constructor of the required accessibility; a class declaring no constructor satisfies `Public`. A struct is never reported (decided 2026-10-13, before the tag): its parameterless constructor, implicit or declared, is public and can be nothing else (CS8958), so it satisfies `Public` as it is, and under any other accessibility the rule reported an Error only a suppression could answer, and the fix wrote a constructor that did not compile. A wrong accessibility is reported at the constructor. The code fix, the package's first, in `CodoMetis.TypeKit.Analyzers.CodeFixes.dll` beside the analyzer, writes or changes the constructor; a type with a primary constructor in any of its parts gets none (CS8862), nor, since 2026-10-14, one whose implicit `base()` binds to nothing (CS7036, CS0122, CS0121), which a review found the fix writing. It writes its own trivia and asks no formatter, which laid out the whole type again and wrote the machine's line endings: doc comments, attributes, comments and the members' layout stay, added lines take the members' indentation and the brace's line ending, a one-line type gets the constructor on its line | Error | built 2026-10-13 for 2.0.0 (phase 13) | | CMTK0012 | A closed set's generated `Match` given an argument by position: every parameter is a `Func` in declaration order, so reordering two members swapped their branches and compiled. Bound, only the generated signature counts; unbound (the declaring project, before weaving), a call named `Match` on a closed set with one argument per member, its names the generators' rule (the member's name, first letter lower-cased, a keyword escaped). The code fix names every argument after the parameter it binds to now | Error | built 2026-10-13 for 2.0.0 (phase 13), after a review found the swap | | CMTK0013 | A closed set's member written outside its declaration: an assignment, compound or `??=`, a deconstruction, an increment, a `ref` or `out` argument, or a writable `ref` taken to it, each also through a `ref` conditional (`(flag ? ref Open : ref Closed) = x`, found by the review of the rule itself). A `ref readonly` local or field reassigned to a member reads it and is silent (a false report found by the weaving review, 2026-10-14). C# allows such a write only in the set's static constructor and static field initializers, which run before (initializers) or after (the constructor's body) the generated initializer that names the members and fills `All`; either way an instance answered to another member's name, `Match` took another's branch, and `All` disagreed with `Parse`. An analyzer, not the aspect: Metalama 2026.1's code model gives no operations of a constructor body, and a textual scan would miss a deconstruction or a `ref` | Error | built 2026-10-14 for 2.0.0, a major, after a review reproduced the swap against the packed generators | diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs index 1063bbe..3ea602a 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeArgumentConstraintAnalyzer.cs @@ -376,22 +376,30 @@ private static bool Proves(TypeKitSymbols symbols, ITypeParameterSymbol forwarde /// [Concrete] whether or not it repeats the attribute, so a call through the class is held /// to it. Without this, the requirement ended at the interface. A method a base class declares /// implements an interface for a derived class that lists it, so the type the method is reached - /// through, , is asked too. + /// through, , is asked too. Each parameter is asked once: a default + /// interface method reached through a derived interface is its own implementation there, and the + /// walk recursed until the compiler's stack overflowed. /// - private static IEnumerable Effective(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol? receiver) + private static IEnumerable Effective(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol? receiver) => + Effective(symbols, parameter, receiver, new HashSet(SymbolEqualityComparer.Default)).Distinct(); + + private static IEnumerable Effective(TypeKitSymbols symbols, ITypeParameterSymbol parameter, ITypeSymbol? receiver, HashSet visited) { + if (!visited.Add(parameter.OriginalDefinition)) return []; + var own = symbols.ConstraintsOn(parameter); if (parameter.DeclaringMethod is not { } method) return own; return own.Concat(Implemented(method.OriginalDefinition, receiver) .Where(implemented => parameter.Ordinal < implemented.OriginalDefinition.TypeParameters.Length) - .SelectMany(implemented => Effective(symbols, implemented.OriginalDefinition.TypeParameters[parameter.Ordinal], receiver))) - .Distinct(); + .SelectMany(implemented => Effective(symbols, implemented.OriginalDefinition.TypeParameters[parameter.Ordinal], receiver, visited))) + .ToList(); } /// /// The methods overrides or implements, explicitly or implicitly, by its /// declaration or for ; a static method implements a static abstract one. + /// Never the method itself, which a default interface method is for an interface that inherits it. /// private static IEnumerable Implemented(IMethodSymbol method, ITypeSymbol? receiver) { @@ -407,7 +415,9 @@ private static IEnumerable Implemented(IMethodSymbol method, ITyp { foreach (var member in contract.GetMembers(method.Name).OfType()) { - if (member.IsGenericMethod && member.IsStatic == method.IsStatic && SymbolEqualityComparer.Default.Equals(type.FindImplementationForInterfaceMember(member)?.OriginalDefinition, method)) + if (member.IsGenericMethod && member.IsStatic == method.IsStatic + && !SymbolEqualityComparer.Default.Equals(member.OriginalDefinition, method) + && SymbolEqualityComparer.Default.Equals(type.FindImplementationForInterfaceMember(member)?.OriginalDefinition, method)) yield return member; } } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs index de0adb0..8aa2d8c 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintAnalyzerTests.cs @@ -467,6 +467,38 @@ [Mark] public sealed class Fine { } Cmtk0010(1, "Handler", "T", "MarkAttribute", "is not a concrete class, record or struct")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A default interface method called through a derived interface is its own implementation there: + /// the analyzer took it for a method it implements, and recursed until the compiler's stack + /// overflowed. It carries its own constraint, once. + /// + [Fact] + public Task A_default_interface_method_reached_through_a_derived_interface_carries_its_own_constraint() => + Test( + """ + public interface IRunner { void Run<[Concrete] T>() { } } + public interface IFastRunner : IRunner { } + public sealed class FastRunner : IFastRunner { } + public interface IMaker { static virtual void Make<[Concrete] T>() { } } + public interface IFastMaker : IMaker { } + + public static class Calls + { + public static void Go(IFastRunner runner, IRunner plain) + { + runner.Run<{|#0:Handler|}>(); + runner.Run(); + plain.Run<{|#1:Handler|}>(); + } + + public static void Generic() where TMaker : IFastMaker => TMaker.Make<{|#2:Handler|}>(); + } + """, + Cmtk0010(0, "Handler", "T", "IRunner.Run", "is not a concrete class, record or struct"), + Cmtk0010(1, "Handler", "T", "IRunner.Run", "is not a concrete class, record or struct"), + Cmtk0010(2, "Handler", "T", "IMaker.Make", "is not a concrete class, record or struct")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// A static method implementing a static abstract interface method, and a base class's method that /// implements an interface for a derived class, carry the interface's constraint; both were From 2e68a70048b921551e4bb3510a9630cfdb006220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 08:06:37 +0200 Subject: [PATCH 143/147] fix: a tap on a held result or task, returned as a plain Task or waited for, no longer reports CMTK0003 Task Log() => pending.TapAsync(log); and held.TapAsync(log).Wait() leave the outcome with the caller, as the same tap as a statement does, on a Result and on a Task of one. Co-Authored-By: Claude Opus 5.5 --- design/analyzers.md | 2 +- .../IgnoredOutcomeAnalyzer.cs | 12 ++++-- src/CodoMetis.TypeKit.Analyzers/README.md | 3 +- .../IgnoredOutcomeAnalyzerTests.cs | 38 +++++++++++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/design/analyzers.md b/design/analyzers.md index d06799a..fd2fbbc 100644 --- a/design/analyzers.md +++ b/design/analyzers.md @@ -33,7 +33,7 @@ exist. |---|---|---|---| | CMTK0001 | No `default`/`default(T)`/`new()`/`new T()` of a value object or a `[RequireCustomInitialization]` type. An operand of `==`/`!=`, or an argument of a call whose name has the word `Equal`, `Equals` or `Compare` or ends in `Be` (`ThrowIfEqual`, `Assert.NotEqual`, `ShouldNotBe`), is exempt: a guard check is the only defence against the defaults the rule cannot see. So is a `default` assigned to an `out` parameter of a method returning `bool`, the Try pattern. Where a sibling branch does not bind (same-project value objects), the target type is taken from the enclosing conditional, switch arm or collection | Error | shipped in 1.0.0 | | CMTK0002 | Type implements `IValue<>`/`IValidatedValue<,,>`, but the compilation does not reference `CodoMetis.TypeKit.Generators`, so it is never woven | Error | shipped in 1.0.0 | -| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver, and so is a chain of them on one (`r.Tap(log).TapError(warn)`, `(await pending).Tap(log)`, `pending.WaitAsync(t).TapAsync(log)`), measured as an Error's false report before 2.0.0 was tagged. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | +| CMTK0003 | `Result`/`Option` a call returns, dropped by an expression statement, awaited or not, or a `Task` of one converted to a plain `Task` (`Task Cancel() => CancelAsync();`); `_ =` opts out. `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` on a stored result or task are exempt, since they return their receiver, and so is a chain of them on one (`r.Tap(log).TapError(warn)`, `(await pending).Tap(log)`, `pending.WaitAsync(t).TapAsync(log)`), measured as an Error's false report before 2.0.0 was tagged; so is such a tap converted to a plain `Task` or waited for (`Task Log() => pending.TapAsync(log);`, `held.TapAsync(log).Wait()`), on a `Result` and on a `Task` of one, which an independent review found reported on 2026-10-14. Also a collection of results the statement made (an array, `ICollection` or `IReadOnlyCollection`, such as `await Task.WhenAll(ids.Select(orders.CancelAsync))`), and a method group of a `Task` method converted to a delegate returning `Task`; tasks held in variables are silent, and so is a lazy `IEnumerable`, where nothing has run, unless a call that was given no result returned it and the statement materialises it. Also `Wait` on a task the expression made, tasks of results the statement made converted to plain ones (`Task.WaitAll`, `WaitAny`), and a `foreach` or `await foreach` over results its header made whose body never reads the iteration variable; a variable named `_` opts out. CA1806 can only enforce this per method name, not per return type | Error | shipped in 1.0.0 at Warning; Error in 2.0.0 ([decision 50](adr/0050-warnings-cmtk0003-alone-becomes-error-2.md)), its 1.x gaps closed first | | CMTK0004 | `IValueObjectMaterializer<,>.Materialize`, `ValueObjectConverter<,>.Materialize` or a hand-written value object's public `Materialize`, called or referenced (expression trees included). The satellite's own call is compiled in the satellite; EF's compiled model is generated code, whose diagnostics are not reported (measured: reported, it is six errors in the smoke test's AOT consumer). `StoredJsonConverterFactory` goes through `IStoredJsonConverterSource`, not `Materialize` | Error | shipped in 1.0.0 | | CMTK0005 | `new T[n]`, `stackalloc T[n]`, `GC.AllocateUninitializedArray/AllocateArray` or `Array.Resize` of a no-default struct: every new slot starts as `default`. A constant length of zero (for `Array.Resize` and the `GC` methods too) or initial elements are silent | Warning | shipped in 1.0.0 | | CMTK0006 | Field or auto-property of a no-default struct type in a class that no initializer, `required` or constructor sets; a non-public parameterless constructor (EF, serializers) and a record's copy constructor are exempt. Measured 2026-09-28 on the repository's EF entities and request types: 12 of 12 reports on settable properties of a type with only the implicit constructor, each a real way to a default. `required`, the fix the message names where it compiles, works with EF's compiled model, precompiled queries and Native AOT (the smoke test rerun with it). A `[SetsRequiredMembers]` constructor must assign the `required` members it promises | Warning | shipped in 1.0.0 | diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index c91afc8..af95757 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -238,7 +238,8 @@ private static bool Given(IInvocationOperation invocation, TypeKitSymbols symbol /// /// A call's Task of a result, or of a collection of results it made, converted implicitly to - /// a plain Task, which has no result to hand on; or tasks of results the statement made, + /// a plain Task, which has no result to hand on, unless it is a tap on a result or task the + /// caller still holds; or tasks of results the statement made, /// converted to plain tasks, an array, a sequence or a span of them, as Task.WaitAll takes. /// private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitSymbols symbols, Tasks tasks) @@ -256,8 +257,9 @@ private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitS foreach (var invocation in Producers(conversion.Operand, tasks)) { + // held.TapAsync(log) returns the task of a result the caller still holds. var outcome = toPlainTask - ? tasks.PendingOutcome(invocation.Type, symbols) + ? (ReturnsItsStoredReceiver(invocation, symbols, tasks) ? null : tasks.PendingOutcome(invocation.Type, symbols)) ?? DroppedHolder(tasks.HolderOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks) : DroppedCollection(tasks.PendingOutcome(tasks.ElementOfSequence(invocation.Type), symbols), invocation, symbols, tasks); @@ -271,8 +273,8 @@ private static void AnalyzeConversion(OperationAnalysisContext context, TypeKitS /// /// Wait on a task of a result, or of a collection of results, that the expression made: /// orders.CancelAsync(id).Wait() blocks until the result exists and hands it to nobody, - /// whether the statement drops what Wait returns or an if reads it. A task still held - /// keeps its result. + /// whether the statement drops what Wait returns or an if reads it. A task still held, + /// or a tap on one, keeps its result. /// private static void AnalyzeWait(OperationAnalysisContext context, TypeKitSymbols symbols, Tasks tasks) { @@ -281,6 +283,8 @@ private static void AnalyzeWait(OperationAnalysisContext context, TypeKitSymbols if (wait.TargetMethod is not { Name: "Wait", IsStatic: false } method || !SymbolEqualityComparer.Default.Equals(method.ContainingType, tasks.Plain)) return; if (wait.Instance is null || tasks.PassedOn(wait.Instance) is not IInvocationOperation invocation) return; + if (ReturnsItsStoredReceiver(invocation, symbols, tasks)) return; + var outcome = tasks.PendingOutcome(invocation.Type, symbols) ?? DroppedHolder(tasks.HolderOutcome(tasks.Awaited(invocation.Type), symbols), invocation, symbols, tasks); if (outcome is null) return; diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index a0f0235..4613680 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -206,7 +206,8 @@ a count from a read; enumerate it with a loop that reads each result, or discard `Tap`, `TapAsync`, `TapError` and `TapErrorAsync` return their receiver unchanged, and so do the `Task` continuations `TapAsync` and `TapErrorAsync`, so `result.Tap(log);` and `await pending.TapAsync(log);` on a variable, parameter or field are silent, and so is a chain of them, `result.Tap(log).TapError(warn);`, -or one on what a held task completes with, `(await pending).Tap(log);`. `Find(id).Tap(log);` drops +or one on what a held task completes with, `(await pending).Tap(log);`. So is such a tap returned as a +plain `Task`, `Task Log() => pending.TapAsync(log);`, or waited for. `Find(id).Tap(log);` drops the result and is reported, and so does `Find(id).Tap(log).TapError(warn);`. ## CMTK0004 diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index 937fc9e..a696136 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -339,6 +339,44 @@ public static async Task Run(Service service, Result parameter, Resu Cmtk0003(6, "Result", "TapAsync")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A tap on a stored result or task, converted to a plain Task or waited for, leaves the + /// outcome with the caller, as the same tap as a statement does: both were reported, on a held + /// Result and on a held Task of one. A tap on what a call made still drops it. + /// + [Fact] + public Task A_tap_on_a_stored_result_or_task_converted_to_a_plain_Task_or_waited_for_is_silent() => + Test( + """ + public static class Caller + { + private static Result _field = 1; + + public static Task FromResult(Result held) => held.TapAsync(_ => Task.CompletedTask); + public static Task FromTask(Task> pending) => pending.TapAsync(_ => { }); + public static Task Chained(Task> pending) => pending.TapAsync(_ => { }).TapErrorAsync(_ => { }); + public static Task Field() => _field.TapErrorAsync(_ => Task.CompletedTask); + public static Task Bounded(Task> pending) => pending.WaitAsync(TimeSpan.FromSeconds(1)).TapAsync(_ => { }); + public static Func Later(Task> pending) => () => pending.TapAsync(_ => { }); + public static Task Either(bool flag, Task> pending, Service service) => + flag ? pending.TapAsync(_ => { }) : {|#2:service.LoadAsync().TapAsync(_ => { })|}; + + public static Task Made(Service service) => {|#0:service.LoadAsync().TapAsync(_ => { })|}; + public static Task MadeFromResult(Service service) => {|#1:service.Load().TapAsync(_ => Task.CompletedTask)|}; + + public static void Blocked(Task> pending, Service service) + { + pending.TapAsync(_ => { }).Wait(); + {|#3:service.LoadAsync().TapAsync(_ => { }).Wait()|}; + } + } + """, + Cmtk0003(0, "Result", "TapAsync"), + Cmtk0003(1, "Result", "TapAsync"), + Cmtk0003(2, "Result", "TapAsync"), + Cmtk0003(3, "Result", "TapAsync")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// The Task continuations TapAsync and TapErrorAsync return the task's result /// unchanged, so awaiting them on a task the caller still holds drops nothing, as the instance From 98f6aaf3db59bb9ea6e2ffb280dac0f26b9bf775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 14 Oct 2026 08:15:49 +0200 Subject: [PATCH 144/147] build: SDK 10.0.402, Metalama 2026.1.30, TestHost 10.0.13 and JsonSchema.Net 9.4.1 10.0.402 ships the October servicing, which fixes six SDK CVEs, and the release packages are built with it; pinned rather than left to latestPatch, so no workflow builds on 10.0.401. Metalama stays on the latest 2026.1 (decision 4), and its compiler moves to 2026.1.20. The other floors stay: EF Core Relational and AspNetCore.OpenApi 10.0.12 (no advisory names either; the EF test providers stay with them so the tests run on the floor), Serilog 4.0.0, Roslyn 5.0.0 (decision 25). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Directory.Packages.props | 6 +++--- global.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ecfa6..5d41736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Still `net10.0` and Metalama 2026.1. What builds or runs differently, and what t - CMTK0003 no longer reports a chain of taps on a result the caller holds. - EF Core: a `char` key stays the application's to assign; a default `ulong` key throws `OverflowException` at `Add` (use `ValueGeneratedNever()`). - Testing failures name a nested type with its containing types. +- `CodoMetis.TypeKit.Generators` depends on `Metalama.Framework` 2026.1.30 or later (was 2026.1.29). ### Fixed diff --git a/Directory.Packages.props b/Directory.Packages.props index 0cf8548..bcebbd9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ --> - + - + - +