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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"version": "1.4.0",
"commands": [
"csharpier"
],
"rollForward": false
},
"dotnet-inspect": {
"version": "0.26.0",
"version": "0.27.0",
"commands": [
"dotnet-inspect"
],
"rollForward": false
},
"purview.build": {
"version": "0.8.0",
"version": "0.9.0",
"commands": [
"purview-build"
],
Expand Down
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Purview.BuildSdk 1.3.0 - managed file, do not edit
# Purview.BuildSdk 2.0.0 - managed file, do not edit
#
# This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version
# on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is
Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,49 @@ that version to nuget.org.
This file was reconstructed from the conventional-commit history at 1.1.1, so entries before that
release summarise what the commits recorded rather than what was written at the time.

## [2.0.0] - 2026-11-10

### Changed

- **.NET 8 and .NET 9 are out of support.** The libraries still ship a `netstandard2.0` asset, so a
.NET 8 or .NET 9 consumer keeps working, but neither runtime is built against or tested. The test
matrix is `net10.0` and `net11.0`, and the benchmarks target the same pair. See
[docs/wiki/DotNet-11.md](docs/wiki/DotNet-11.md).
- **The repository requires the .NET 11 SDK** (`11.0.100`, with `allowPrerelease` now `false` because
.NET 11 has shipped) and `Purview.BuildSdk` 2.0.0.
- **The packaged merge tool supports .NET 10 and .NET 11 from one `tools/` folder.** It targets the
.NET 10 LTS floor with `RollForward=LatestMajor`, so `dotnet exec` uses the installed .NET 10 or
.NET 11 runtime and prefers 11 when both are present. `MergeToolTFM` selects both the build output
and the packed folder, and the merge-tool test suite runs on both runtimes.
- **Roslyn is pinned to `Microsoft.CodeAnalysis` 5.9.0** instead of a floating `5.9.0+` range. A host
that loads the generators must ship Roslyn 5.9 or later; the .NET 11 SDK ships 5.12.
- **`CodeQueryResultAssertion<T>` is `closed` on the `net11.0` asset (C# 15).** It is the public base
for the package's sealed assertion chain, not a consumer extension point, so deriving from it in
another assembly now fails with CS9382 there. Write a custom chained assertion against TUnit's
`Assertion<T>` instead. The `netstandard2.0` and `net10.0` assets keep the previous `abstract`
declaration, because the compiler attribute the modifier emits ships only with .NET 11.

### Added

- [docs/wiki/DotNet-11.md](docs/wiki/DotNet-11.md): the support matrix, the .NET 11 features that
`Purview.BuildSdk` enables for `net11.0` (Runtime Async, C# 15, memory-safety rules, `-mt`), and the
features that were evaluated and deliberately left off. It records why no union type was required
and the non-boxing `IUnion`/`HasValue`/`TryGetValue` pattern to use if one is added.
- `dotnet test --results-directory-layout per-module` in the `just test` recipe. A multi-target run
previously had `net10.0` and `net11.0` overwrite each other's reports.
- `minPublishAgeHours="24"` on the `nuget.org` source, the .NET 11 SDK setting that keeps a
just-published package out of automatic version selection until it has had a day of review.

### Fixed

- The merge tool's target framework disagreed with itself: the projects built `net11.0` but looked
for the tool under `bin/<Configuration>/net10.0`, and the packaged targets looked under
`tools/net10.0`. Both now flow from a single property.
- Removed the redundant `System.Reflection.Metadata` package reference from the build-integration
tests, clearing NU1510 (the assembly is in the shared framework for `net10.0`/`net11.0`).
- `just benchmark` now passes `--framework net11.0`; `dotnet run` cannot choose a target framework for
a multi-targeted project.

## [1.3.0] - 2026-11-03

### Changed
Expand Down
22 changes: 11 additions & 11 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
<Project>
<PropertyGroup>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<RoslynCompilerVersion Condition="'$(RoslynCompilerVersion)' == ''">[5.9.0,)</RoslynCompilerVersion>
<RoslynAnalyzersVersion Condition="'$(RoslynAnalyzersVersion)' == ''">[5.9.0,)</RoslynAnalyzersVersion>
<TUnitVersion>[1.76.0,)</TUnitVersion>
<RoslynCompilerVersion Condition="'$(RoslynCompilerVersion)' == ''">5.9.0</RoslynCompilerVersion>
<RoslynAnalyzersVersion Condition="'$(RoslynAnalyzersVersion)' == ''">5.9.0</RoslynAnalyzersVersion>
<TUnitVersion>1.83.0</TUnitVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Bogus" Version="[35.6.5,)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="[8.0.0,)" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="[10.0.402,)" />
<PackageVersion Include="Bogus" Version="35.6.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.15" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="11.0.100" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynCompilerVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="$(RoslynCompilerVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="$(RoslynAnalyzersVersion)" />
<PackageVersion Include="TUnit" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Core" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Assertions" Version="$(TUnitVersion)" />
<PackageVersion Include="TUnit.Mocks" Version="$(TUnitVersion)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="[8.0.0,)" />
<PackageVersion Include="System.Collections.Immutable" Version="[10.0.1,)" />
<PackageVersion Include="System.Reflection.Metadata" Version="[10.0.1,)" />
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.15" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.15" />
<PackageVersion Include="System.Reflection.Metadata" Version="10.0.15" />
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.15" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="ILRepack.Lib" Version="2.0.48" />
<PackageVersion Include="Mono.Cecil" Version="0.11.6" />
<PackageVersion Include="Purview.Skills.Testing" Version="1.2.0" />
<PackageVersion Include="Purview.Skills.Testing" Version="2.0.0" />
</ItemGroup>
</Project>
4 changes: 2 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ build *args:
test filter=default_test_filter *args:
echo "Running tests for {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}"
echo " and filter {{ GREEN }}{{ filter }}{{ NORMAL }}."
dotnet test {{ solution }} -c {{ build_configuration }} --treenode-filter "{{ filter }}" {{ args }}
dotnet test {{ solution }} -c {{ build_configuration }} --treenode-filter "{{ filter }}" --results-directory-layout per-module {{ args }}

# Run unit tests only
[group('Build and Test')]
Expand All @@ -93,7 +93,7 @@ test-unit *args:
[group('Build and Test')]
benchmark *args:
echo "Running benchmarks for {{ BLUE }}{{ solution }}{{ NORMAL }}"
dotnet run --project {{ benchmark }} --configuration Release --no-build {{ args }}
dotnet run --project {{ benchmark }} --configuration Release --framework net11.0 --no-build {{ args }}

# Clean all projects with the specified configuration, defaulting to "Debug"
[group('Build and Test')]
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A set of libraries for building and testing incremental C# source generators usi
- [CodeWriter structured API reference](docs/wiki/Code-Writer.md)
- [Step-cache tests for incremental generators](docs/wiki/Step-Cache-Tests.md)
- [Benchmark results](docs/wiki/Performance.md)
- [.NET 11 support and features](docs/wiki/DotNet-11.md)

## Packages

Expand All @@ -24,9 +25,10 @@ A set of libraries for building and testing incremental C# source generators usi

## Requirements

- .NET SDK 10.0 or later
- The test projects target `net8.0`, `net9.0`, and `net10.0`
- .NET SDK 11.0 or later (the repository pins `11.0.100` in `global.json`)
- The test projects target `net10.0` and `net11.0`; .NET 8 and .NET 9 are out of support
- Source generators target `netstandard2.0`
- The packaged merge tool targets `net10.0` and rolls forward, so it runs on the .NET 10 or .NET 11 runtime while a component is built

## Building

Expand Down
165 changes: 165 additions & 0 deletions docs/wiki/DotNet-11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# .NET 11 support and features

Version 2.0.0 moves the repository to .NET 11 and drops .NET 8 and .NET 9 from the build and test
matrix. .NET 11 is the current release; .NET 10 remains the LTS target.

## Support matrix

| Component | Target frameworks |
| --- | --- |
| Source generators, analysers, code fixers | `netstandard2.0` |
| `Purview.SourceGeneratorFramework` | `netstandard2.0` |
| `Purview.SourceGeneratorFramework.Testing` / `.Testing.TUnit` | `netstandard2.0`, `net10.0`, `net11.0` |
| Test projects | `net10.0`, `net11.0` |
| Merge tool (packaged under `tools/`) | `net10.0`, rolls forward to `net11.0` |
| Benchmarks | `net10.0`, `net11.0` |

A .NET 8 or .NET 9 consumer still resolves the `netstandard2.0` assets of the libraries, but those
runtimes are no longer built against or tested.

## Requirements

- **Build the repository:** .NET SDK 11.0.100 or later. `global.json` pins that SDK and sets
`allowPrerelease: false` now that .NET 11 has shipped.
- **Load a component as an analyser:** Roslyn 5.9 or later, which is the version the framework
compiles against. The .NET 11 SDK ships Roslyn 5.12 (C# 15 / .NET 11 generation), so it satisfies
the requirement; a Visual Studio 2026 release that ships Roslyn 5.9 or later does too.
- **Build a component that embeds the framework:** .NET 10 or later. The merge tool targets the
.NET 10 LTS floor and sets `RollForward=LatestMajor`, so `dotnet exec` runs it on the installed
.NET 10 or .NET 11 runtime (preferring 11 when both are present), independent of the consuming
project's target framework.

## What the .NET 11 SDK enables for `net11.0`

`Purview.BuildSdk` 2.0.0 applies these to every project that targets `net11.0`; the repository does
not repeat them per project.

| Feature | How it is applied |
| --- | --- |
| Runtime Async (V2) | `<Features>runtime-async=on</Features>`, without `EnablePreviewFeatures` |
| C# 15 | `LangVersion=latest` — collection expression arguments, closed hierarchies, extension indexers, labelled `break`/`continue` |
| Memory safety tier 2 | `updated-memory-safety-rules` with `MemorySafetyRules=2`; tiers `0`, `1` and `2` are supported |
| .NET 11 analysis rules | `AnalysisLevel=latest`, `AnalysisMode=All`; .NET 11 fixed `latest` resolving to the .NET 9 rule set |
| Multithreaded build | MSBuild `-mt`, written into the managed `Directory.Build.rsp` |
| Warmer builds | The .NET 11 SDK enables the MSBuild server by default and matches wildcards faster during evaluation |
| Faster CLI | The SDK's `dotnet` host is NativeAOT-compiled by default (`DOTNET_CLI_ENABLEAOT=false` opts out) |

Runtime Async and the memory-safety rules only apply to the `net11.0` leg. The `netstandard2.0` and
`net10.0` legs share the C# 15 language level but none of the net11-only runtime behaviour, so shared
sources must stay free of APIs that exist only on .NET 11.

Opting out is per project: `<PurviewRuntimeAsync>false</PurviewRuntimeAsync>` (or the compiler's
`<UseRuntimeAsync>false</UseRuntimeAsync>`), and `<MemorySafetyRules>0</MemorySafetyRules>`.

## What this repository turned on

- **`--results-directory-layout per-module`** on `dotnet test` (the `just test` recipe). A
multi-target run previously had `net10.0` and `net11.0` writing reports with the same relative file
name; the .NET 11 option gives each test application its own directory.
- **`minPublishAgeHours="24"`** on the `nuget.org` source. The .NET 11 SDK writes this setting for
`dotnet nuget add source --min-publish-age-hours`. It only affects version selection for
`dotnet package add/update` and floating references, so the repository's pinned versions restore
normally while a newly published, not-yet-reviewed version cannot be picked up by accident.
- **Benchmarks default to `net11.0`.** `dotnet run` cannot choose a target framework for a
multi-targeted project, so the recipe now passes `--framework net11.0`; run
`dotnet run --project src/src/SourceGeneratorFramework.Benchmarks --framework net10.0` directly
when a `net10.0` comparison is wanted.
- **No redundant `System.Reflection.Metadata` package.** It is in the shared framework for
`net10.0`/`net11.0`, so referencing it produced NU1510. The `netstandard2.0` library still needs the
package reference.
- **The merge tool's target framework is a single property, and it serves both supported runtimes.**
`$(MergeToolTFM)` selects the build output and the packed `tools/` folder, and
`_PurviewSourceGeneratorFrameworkMergeToolTargetFramework` is the packaged default. Both are
`net10.0`, with `RollForward=LatestMajor` in the tool's runtimeconfig; previously the projects built
`net11.0` while looking under `net10.0`.
- **`closed` on `CodeQueryResultAssertion<T>`, on the `net11.0` asset.** The assertion chain is a fixed
set owned by the package; the public base exists for the sealed assertions, not as a consumer
extension point. `closed` turns that into a compile-time guarantee. `RoslynTestRunner` was the other
candidate but cannot be closed: its derived runners are generic and C# 15 requires a derived generic
type to reference its type parameters in a closed base (CS9383). The `netstandard2.0` and `net10.0`
assets keep the previous `abstract` declaration, because only .NET 11 ships `IsClosedTypeAttribute`.

## Union types and closed hierarchies

Both C# 15 features were evaluated against the API surface rather than adopted wholesale.

### Union types

No type in this repository is a true exactly-one-of today, so no union was introduced:

| Candidate | Why it is not a union |
| --- | --- |
| `GeneratorResult<T>` | Carries a value **and** diagnostics (plus blocking/error flags) at the same time. A union is exactly one case, so this would lose information and break every pipeline. |
| `AttributeDataModelProperty` / `PropertySource`, the `TypeLibrary*` models | Sealed records whose value equality is the incremental cache contract. A union adds no equality or `Deconstruct` (the language reference is explicit about this), so converting would require hand-written equality and risk cache invalidation. |
| `ComponentGuard.TryRun` | Returns `bool` plus a `Diagnostic?`. `Diagnostic` is already a reference, so there is no box to remove; a union would add API churn and a second way to express the same result. |
| `AttributeArgument` | Represents a Roslyn `TypedConstant` (an open set of kinds), not a closed set of case types. |

If a union is added later, it must use the **non-boxing access pattern** from the language
reference: a custom `[Union]` type implementing `IUnion` with a `HasValue` property and a
`TryGetValue(out TCase)` overload per case type. The `union` declaration is not suitable for
performance-sensitive value-type cases because it always stores `object?` and boxes them. The
compiler calls `TryGetValue` for type patterns and `HasValue` for null patterns, so matching never
boxes; a missing member silently falls back to the boxing `Value` path.

```csharp
using System.Runtime.CompilerServices;

[Union]
public struct IntOrError : IUnion
{
readonly int _value;
readonly Exception? _error;
readonly byte _tag; // 0 = none, 1 = int, 2 = error

public IntOrError(int value) { _value = value; _tag = 1; }
public IntOrError(Exception error) { _error = error; _tag = 2; }

public object? Value => _tag switch { 1 => _value, 2 => _error, _ => null };
public bool HasValue => _tag != 0;
public bool TryGetValue(out int value) { value = _value; return _tag == 1; }
public bool TryGetValue(out Exception error) { error = _error!; return _tag == 2; }
}
```

### Closed hierarchies

`CodeQueryResultAssertion<T>` is declared `closed` on the `net11.0` asset. It is a public base for a
fixed set of sealed assertion types, and external derivation is now rejected with CS9382 there instead
of being an unsupported-but-possible extension. A consumer that wants its own chained assertion should
derive from TUnit's `Assertion<T>` directly. The other assets keep `abstract` so their consumers, which
may not compile with a C# 15 compiler, are unaffected.

`RoslynTestRunner` is the other infrastructure base, but C# 15 rejects a closed base whose derived
types are generic and do not reference their type parameters in it (CS9383); the concrete runners are
`CodeFixTestRunner<TAnalyzer, TCodeFix>`, `DiagnosticAnalyzerTestRunner<TAnalyzer>` and
`RefactoringTestRunner<TRefactoring>`. The documented test bases (`AnalyzerTestBase`,
`SourceGeneratorTestBase` and the TUnit variants) are consumer extension points and stay open.

## Evaluated and deliberately not enabled

| Feature | Why not |
| --- | --- |
| `dotnet test --timeout` / `--maximum-failed-tests` as defaults | The reusable workflow already bounds the job. A local default can abort a legitimate long build-integration run; pass the options per invocation instead. |
| SDK artifacts output layout (`ArtifactsPath`) | The Roslyn-version inner build sets its own `BaseOutputPath`/`BaseIntermediateOutputPath`, and pack validation asserts paths under `bin/`; moving to the artifacts layout would change both. |
| NativeAOT or ReadyToRun for the merge tool | The tool ships inside a framework-neutral package. AOT output is per-RID, and R2R would add architecture-specific binaries to `tools/`. |
| `PublishTrimmed` / `InvariantGlobalization` for the merge tool | Not proven safe for ILRepack/Mono.Cecil, which use reflection and culture-sensitive paths. |
| C# 15 runtime-dependent features in shared sources | `closed` is a declaration-only feature and is used above. Union types and net11-only APIs are not, because the `netstandard2.0`/`net10.0` legs still compile shared sources and the language-level declaration would not remove boxing. |
| .NET 11 `Process` helpers, `EqualityComparer<T>.Create`, the new `Stream` wrappers | The call sites are either multi-targeted (would need `#if NET11_0_OR_GREATER`) or on the `netstandard2.0` legs, where the APIs do not exist. No measured benefit for this codebase. |
| `NuGetAuditMode=all` | Auditing direct dependencies is the SDK default and currently reports nothing; transitive auditing can be enabled if the restore cost becomes worthwhile. |
| In-process crash reports (`DOTNET_EnableCrashReport=1`) | Useful when diagnosing a test-host crash on Linux/macOS, but it writes a report to disk, so it stays opt-in. |

## Useful .NET 11 switches

```bash
# Run tests with a whole-run limit or failure cap
dotnet test src/SourceGeneratorFramework.slnx --timeout 20m --maximum-failed-tests 5

# Select the runner for one session instead of editing global.json
DOTNET_TEST_RUNNER=Microsoft.Testing.Platform dotnet test src/SourceGeneratorFramework.slnx

# Pass environment variables without a launch profile
dotnet run -e DOTNET_TieredPGO=1 --project src/src/SourceGeneratorFramework.Benchmarks --framework net11.0

# Skip building project references when the tree is already built
dotnet test src/SourceGeneratorFramework.slnx --no-build --no-dependencies
```
Loading
Loading