diff --git a/design/adr/0118-an-error-may-learn-a-form-only-new-api-can-write.md b/design/adr/0118-an-error-may-learn-a-form-only-new-api-can-write.md new file mode 100644 index 0000000..7bb3e79 --- /dev/null +++ b/design/adr/0118-an-error-may-learn-a-form-only-new-api-can-write.md @@ -0,0 +1,49 @@ +# 0118. An Error may learn, in a minor version, a form only that version's new API can write + +Date: 2026-10-29 +Status: accepted + +Context: 2026-10-29, issue #153, found by the pre-release sweep of `release/2.3.0` at `99c6032`; refines +[decision 34](0034-errors-fixed-within-major-version-warnings.md). 2.3.0 gives every assertion of +`CodoMetis.TypeKit.Testing` a form on a `Task` or `ValueTask` of what it checks (#87), and CMTK0003, an +Error since 2.0.0 ([decision 50](0050-warnings-cmtk0003-alone-becomes-error-2.md)), reports such an +assertion left without `await` (`IgnoredOutcomeAnalyzer.IsPendingAssertion`): it fails the test only through +the task it returns, so the dropped task passes whatever the value is. Decision 34 says an Error that +reports more waits for a major version, and the Analyzers README promised the same, so the 2.3.0 analyzer +contradicted the 2.3.0 README with no record of why. + +## Decision + +**An Error may report, in a minor version, a form that only an API new in that same version can write.** +Decision 34 keeps Errors fixed within a major so that an update never fails a build that worked before. +A form that needs the new API to be written cannot occur in code that built on the previous version, so +reporting it fails no such build. The rule holds when every reported form calls the new API itself; a +form that merely sits beside it, or that a name alone recognises, does not qualify. + +**What qualifies in 2.3.0.** CMTK0003's dropped pending assertion: an expression statement that drops, +without `await`, the task an assertion of `CodoMetis.TypeKit.Testing` returns on a `Task` or `ValueTask`. +The rule knows the assertion by the package's own assembly (`TypeKitSymbols.IsTestingAssembly`), so a +consumer's extension of the same name on a pending result, which may have built on 2.2.0, stays silent +(`IgnoredOutcomeAnalyzerTests.A_task_of_another_assembly_s_extension_on_a_pending_value_is_silent`). No +preview of 2.3.0 was published (nuget.org lists 2.2.0 as the latest of both packages on 2026-10-29), so +no build on these assertions exists before the tag. + +**It covers #152's forms.** The same assertions passed through `ConfigureAwait` or `WaitAsync` of a plain +`Task`, and reached through a branch of a `?:`, a `??` or a `switch` expression, are written only with +the same new API, so their reach widens before the tag under this decision. + +**The forms freeze at the tag.** Once 2.3.0 ships, its API is no longer new: a further form of these +assertions, or of any other 2.3.0 API, that CMTK0003 learns afterwards waits for a major version, as +decision 34 says. A Warning or an Info may still learn more forms in any minor version. + +**One exception in 2.3.0, taken by the maintainer.** #159: CMTK0003 also reports a `Task.WhenAny` of tasks +of results dropped without `await`, converted to a plain `Task` or waited for with `Wait`, which code built +on 2.2.0 can contain. The maintainer took it into 2.3.0 on 2026-10-29, before the tag: such a statement +loses every result in any build, so the report finds a defect, never correct code. A `WhenAny` of tasks the +caller holds completes with one of them and stays silent. The Analyzers README names the exception beside +the rule. + +**Decision 34 stands otherwise.** A new rule ships at Warning or Info in a minor version, and an Error +that reports more of the code a consumer could already write waits for a major. The Analyzers README's +"What a new version may change" states both, and claim `T01-an-error-learns-only-new-api-forms` in +`test/oracles/claims.json` quotes the exception. diff --git a/design/adr/README.md b/design/adr/README.md index cb8d9aa..4d325d4 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -40,7 +40,7 @@ per area ([design](../plan.md)) link to the records they rest on. | [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 | +| [0034](0034-errors-fixed-within-major-version-warnings.md) | Errors are fixed within a major version; Warnings may learn | 2026-09-28 | accepted; refined by [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | | [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 | @@ -124,3 +124,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0115](0115-value-objects-can-be-inlined-everywhere.md) | `AddTypeKit(typeKit => typeKit.InlineValueObjects = true)` writes every value object in place, by its wrapped type's schema, in every position and version, with no component of its own; off by default, the document unchanged; closed sets and wrapped enums keep their components; an inlined value object claims no name (decision 55) and shares no schema between its JSON positions and its parameters (decision 106) | 2026-10-29 | accepted | | [0116](0116-the-docs-site-is-docfx-built-from-main.md) | The docs site is DocFX, published to GitHub Pages from `main` alone: the guides, the READMEs, the changelog and the API pages of the run-time packages, from the files as they are; a link to a file that is not a page becomes a GitHub address at the built commit; a pull request touching a page builds it with warnings as errors; `site/**` is prose; DocFX 2.81.0 lists the members of C# 14 extension blocks as extension methods | 2026-10-29 | accepted | | [0117](0117-a-seed-is-caught-against-a-passing-baseline.md) | `oracles seeds` credits a seed only when its oracle passes without it, in a baseline run with the same build and filter, and fails with it in a test of its own; a run that never reached the tests (a fixture xunit could not set up, an error counted outside any test, a crashed host, no TRX, every test skipped) or a failing baseline is `ERROR`, exit 4, never `CAUGHT` | 2026-10-29 | accepted | +| [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted | diff --git a/design/analyzers.md b/design/analyzers.md index 411befb..b0a2bc6 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; 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 | +| 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. From 2.3.0, an assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` of what it checks, dropped by an expression statement without `await`: `service.FindAsync(id).ShouldBeNone();`, `service.LoadAsync().ShouldBeSuccess().ConfigureAwait(false);`, the body of a lambda that returns nothing; it fails the test only through its task. Known by the package's assembly, so a consumer's extension of the same name on a pending result is silent; awaited, held, returned or discarded with `_ =`, it is silent too. 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; the dropped pending assertion in 2.3.0, a form only 2.3.0's API writes ([decision 118](adr/0118-an-error-may-learn-a-form-only-new-api-can-write.md)) | | 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 | @@ -78,5 +78,7 @@ 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). +update. Within a major version an Error never reports more than it did, unless the form is one only +that minor version's new API can write ([decision 118](adr/0118-an-error-may-learn-a-form-only-new-api-can-write.md), +which also records 2.3.0's one exception, #159); +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/roadmap.md b/design/roadmap.md index 810757c..cb43946 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -3,7 +3,8 @@ 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, or the review that found it. A Warning may learn and a refusal may relax in any minor version; an Error that reports more waits for a major -([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)). +([decision 34](adr/0034-errors-fixed-within-major-version-warnings.md)), unless only that minor version's +new API can write the form ([decision 118](adr/0118-an-error-may-learn-a-form-only-new-api-can-write.md)). ## 2.2.0, consolidation diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 3fa4a1b..58b6e7f 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -756,11 +756,20 @@ but a suppression to say so: - a sequence known not to be empty (CMTK0009); - a comparison of two identities that really are shared (CMTK0008). -**What a new version may change.** 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 version, such as another method that returns a default instance -for CMTK0009. In a build that treats warnings as errors, such an update can report code that built -before, as a newer compiler's warnings can. +**What a new version may change.** Within a major version, an update never makes an Error report code +that built before. A new Error rule, or an Error that reports more of the code you could already write, +waits for the next major version. A minor version may teach an Error a form that only its own new API can +write. In 2.3.0, CMTK0003 reports a `CodoMetis.TypeKit.Testing` assertion on a `Task` or `ValueTask` left +without `await`, and those assertions are new in 2.3.0. Your code from before the update can't contain +one, so it builds as before. + +2.3.0 makes one exception. CMTK0003 now also reports `Task.WhenAny` over tasks of results left without +`await`, which code from before 2.3.0 can contain. Such a line loses every result, so the build points at +a real bug. Handle the results, or discard the task with `_ =`. + +A Warning or an Info may learn more forms in a minor version, such as another method that returns a +default instance for CMTK0009. In a build that treats warnings as errors, such an update can report code +that built before, as a newer compiler's warnings can. ## Generated code and Razor diff --git a/test/oracles/claims.json b/test/oracles/claims.json index bbae78b..344b875 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -4743,6 +4743,21 @@ "DestructuringTests" ] }, + { + "id": "T01-an-error-learns-only-new-api-forms", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#configuration", + "quote": "A minor version may teach an Error a form that only its own new API can write.", + "claim": "Within a major version an Error reports nothing more of the code a consumer could already write; a minor version's Error reports only forms that call that version's new API, such as 2.3.0's CMTK0003 on an unawaited Testing assertion over a Task or ValueTask, recognised by the Testing assembly so that a consumer's own extension of the same name stays silent.", + "oracle": "claim", + "surfaces": [ + "OptionAssertions.*", + "ResultAssertions.*", + "PatchAssertions.*" + ], + "heldBy": [ + "IgnoredOutcomeAnalyzerTests" + ] + }, { "id": "W01-answers-are-real", "source": "docs/walkthrough.md#walkthrough-one-orders-service",