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
17 changes: 14 additions & 3 deletions .github/workflows/egil-systemtextjson-migration-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ env:
permissions:
contents: read

# SDK band policy: every setup-dotnet step below installs the 10.x band plus
# SDK band policy: every setup-dotnet step below installs the 8.x, 9.x and 10.x bands plus
# the .NET 11 SDK because the package and all of its projects (src, test, perf,
# samples) multi-target net10.0 and net11.0. The .NET 11 SDK is pinned to the
# samples) multi-target net8.0, net9.0, net10.0 and net11.0. The .NET 11 SDK is pinned to the
# RC1 build because setup-dotnet applies dotnet-quality to every listed band and
# an "11.0.x" wildcard would not resolve a prerelease. Replace the pin with
# "11.0.x" at .NET 11 GA (2026-11-10). Keep these bands in sync with the retained
Expand All @@ -48,6 +48,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.x
9.x
10.x
11.0.100-rc.1.26425.128

Expand All @@ -58,7 +60,10 @@ jobs:
dotnet build ./src/Egil.SystemTextJson.Migration.Analyzers/Egil.SystemTextJson.Migration.Analyzers.csproj -c Release
dotnet build ./src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj -c Release
dotnet pack ./src/Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.csproj -c Release --no-build --output ${{ env.NuGetDirectory }}
pwsh -NoProfile -File ./scripts/verify-analyzer-package.ps1 -PackagePath "$(find ${{ env.NuGetDirectory }} -maxdepth 1 -name '*.nupkg' -print -quit)"
# setup-dotnet installs SDKs in this job; this script verifies one target per call.
for framework in net8.0 net9.0 net10.0 net11.0; do

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI will likely break when .NET 8 and 9 go out of support (2026-11-10, same day as .NET 11 GA).

  • After the SDK pin moves to 11.0.x (per the workflow comment), global.json rolls forward to that SDK.
  • It will likely emit NETSDK1138 (target framework out of support) for net8.0 and net9.0.
  • This loop builds the consumers with -warnaserror, and Release builds set TreatWarningsAsErrors in the root Directory.Build.props, so that warning becomes an error. SDK 9.0.100 did the same to net6.0 on its release day.

Suggested fix: opt out of the check with the documented CheckEolTargetFramework property. It states intent better than adding NETSDK1138 to NoWarn.

  1. Library/test/perf/sample projects: add it to the root Directory.Build.props (or to a package-level one, if you'd rather keep it scoped to this package):
    <PropertyGroup Label="Compile settings">
      <!-- Deliberately keep targeting out-of-support TFMs (net8.0, net9.0). -->
      <CheckEolTargetFramework>false</CheckEolTargetFramework>
    </PropertyGroup>
  2. Generated consumer projects: these don't pick up the repo props. verify-analyzer-package.ps1 writes them to a temp dir, and verify-trimming-package.ps1 stubs out Directory.Build.props. So add the property inline to each generated <PropertyGroup>: Consumer.csproj, InvalidConsumer.csproj and SeverityConsumer.csproj in verify-analyzer-package.ps1, plus the trimming consumer in verify-trimming-package.ps1:
    <CheckEolTargetFramework>false</CheckEolTargetFramework>
    (Alternative: pass -p:CheckEolTargetFramework=false to each dotnet restore/build/publish call in the scripts.)

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added CheckEolTargetFramework=false in package-level Directory.Build.props, which explicitly imports the root props so existing build settings remain intact. It is also inline in all three analyzer consumer projects and the trimming/AOT consumer project. Other packages retain their existing SDK support checks.

The Release solution build passed on all four targets with zero warnings/errors. I simulated SDK end-of-support checks for .NET 8/9 across the package projects and all four generated consumer templates: the new settings passed, while controls with CheckEolTargetFramework=true failed with NETSDK1138.

Updated in 87b1f31; the PR still contains one commit.

pwsh -NoProfile -File ./scripts/verify-analyzer-package.ps1 -TargetFramework "$framework" -PackagePath "$(find ${{ env.NuGetDirectory }} -maxdepth 1 -name '*.nupkg' -print -quit)"
done

- name: Install NativeAOT prerequisites
run: |
Expand Down Expand Up @@ -102,6 +107,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.x
9.x
10.x
11.0.100-rc.1.26425.128

Expand Down Expand Up @@ -135,6 +142,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.x
9.x
10.x
11.0.100-rc.1.26425.128

Expand Down Expand Up @@ -168,6 +177,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.x
9.x
10.x
11.0.100-rc.1.26425.128

Expand Down
2 changes: 1 addition & 1 deletion Egil.SystemTextJson.Migration/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Use the solution file from repository root:
- `dotnet outdated`: check for dependency updates.

## Coding Style & Naming Conventions
- Language/runtime: C# on `net10.0` and `net11.0` (multi-targeted), nullable enabled. .NET 11 specific code lives under `#if NET11_0_OR_GREATER`.
- Language/runtime: C# on `net8.0`, `net9.0`, `net10.0` and `net11.0` (multi-targeted), nullable enabled. .NET 11 specific code lives under `#if NET11_0_OR_GREATER`.
- Indentation: 4 spaces for C#; follow `.editorconfig` for other file types.
- Prefer file-scoped namespaces and explicit braces.
- Use `var` when the type is obvious; keep naming in PascalCase for types/methods.
Expand Down
10 changes: 10 additions & 0 deletions Egil.SystemTextJson.Migration/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project>
<Import Project="../Directory.Build.props" />

<PropertyGroup>
<!-- Retain older framework assets for hosts that cannot upgrade their runtime.
Their support end dates must not make newer SDKs reject these builds.
TODO: Re-enable this check when the retained targets are all supported. -->
<CheckEolTargetFramework>false</CheckEolTargetFramework>
</PropertyGroup>
</Project>
4 changes: 3 additions & 1 deletion Egil.SystemTextJson.Migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

Version-tolerant JSON migration for `System.Text.Json`.

Supports .NET 8, .NET 9, .NET 10, and .NET 11 using each target's framework-provided `System.Text.Json`; the package does not require a newer JSON package in .NET 8 or .NET 9 hosts. C# union support is available only in the .NET 11 asset.

When data models evolve, old JSON payloads still exist — in databases, caches, queues, and on disk. This library migrates those payloads to the current type **automatically during deserialization**, so application code never deals with obsolete shapes.

**Key characteristics:**
Expand Down Expand Up @@ -484,7 +486,7 @@ builder.Services.AddOpenTelemetry()

Every benchmark compares the library against hand-written migration code on top of plain `System.Text.Json`. The small profile is a minimal `{ "name": "...", "age": ... }` object that highlights worst-case fixed overhead; the medium profile is a best-guess average object with about 12 object members; and the large profile has about 96 object members spread across nested objects, arrays, and dictionary entries. See [benchmark payload examples](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/payload-examples.md) for representative JSON from each profile.

The generated table below is refreshed by `.\scripts\update-perf-docs.ps1` from the latest source-generated BenchmarkDotNet report, produced with the `net11.0` build of the benchmarks (the union dispatch scenario only exists there; every other scenario also runs on `net10.0`). It keeps BenchmarkDotNet's `Ratio`, `RatioSD`, and `Alloc Ratio` columns so README numbers stay tied to the raw benchmark output.
The generated table below is refreshed by `.\scripts\update-perf-docs.ps1` from the latest source-generated BenchmarkDotNet report, produced with the `net11.0` build of the benchmarks (the union dispatch scenario only exists there; every other scenario also runs on `net8.0`, `net9.0` and `net10.0`). It keeps BenchmarkDotNet's `Ratio`, `RatioSD`, and `Alloc Ratio` columns so README numbers stay tied to the raw benchmark output.

<!-- This is a summary; see [full source-gen results](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/source-gen-benchmarks.md) and [full reflection results](https://github.com/egil/framework/blob/main/Egil.SystemTextJson.Migration/docs/perf/reflection-benchmarks.md). -->

Expand Down
11 changes: 10 additions & 1 deletion Egil.SystemTextJson.Migration/docs/recipes/aot-source-gen.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ Two consequences of living in the resolver chain:

Options that have no resolver at all still serialize through reflection, as they would without the library.

On .NET 8 and .NET 9, assigning a decorated `options.TypeInfoResolver` back to the same options can create a self-referencing chain because System.Text.Json mutates the options-bound chain during assignment. Snapshot the entries before decorating them:

```csharp
var resolver = JsonTypeInfoResolver.Combine(options.TypeInfoResolverChain.ToArray());
options.TypeInfoResolver = resolver.WithAddedModifier(ModifyContract);
```

Alternatively, configure the base resolver and its modifiers before calling `AddJsonMigrationSupport()`.

## What the library does at runtime

Migration itself is driven by `static abstract` interface methods and the type metadata your `JsonSerializerContext` provides; once a type's converter has been created, the library's own read and write paths do not use reflection (System.Text.Json's converter and metadata resolution behaves as it does without the library).
Expand All @@ -71,4 +80,4 @@ Discovery does. When a converter is created for a `[JsonMigratable]` type — on

The [deployment requirements above](#aot--source-generation) follow from migration's interface discovery, assembly scanning, external migrator activation, and runtime generic construction. JSON source generation supplies serialization metadata but does not replace those operations. Even `RegisterMigrator<TSource, TTarget, TMigrator>()` currently enters the reflection-based invoker factory. Trimming can remove required contracts or constructors, and NativeAOT may lack code for runtime generic instantiations. Failures can include missing migrators when reading old payloads even when current payloads appear to work.

The library enables trim and AOT analyzers on both supported target frameworks without declaring `IsTrimmable` or `IsAotCompatible`. Member-preservation annotations and warning-free library analysis do not establish compatibility. A supported NativeAOT path requires separate implementation and published runtime evidence.
The library enables trim and AOT analyzers on all supported target frameworks without declaring `IsTrimmable` or `IsAotCompatible`. Member-preservation annotations and warning-free library analysis do not establish compatibility. A supported NativeAOT path requires separate implementation and published runtime evidence.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Applying the attribute to a type whose contract is not a JSON object now throws

## .NET 11

The package multi-targets `net10.0` and `net11.0`. On .NET 11, `AddJsonMigrationSupport()` also registers `JsonMigratableUnionTypeClassifier`, so a C# `union` whose cases are `[JsonMigratable]` types is classified by migration discriminator. Source-generated contexts must name the classifier on the union (`[JsonUnion(TypeClassifier = typeof(JsonMigratableUnionTypeClassifier))]`); see [polymorphism.md](polymorphism.md). The `[JsonPolymorphic]` limitation is unchanged.
The package multi-targets `net8.0`, `net9.0`, `net10.0` and `net11.0`. On .NET 11, `AddJsonMigrationSupport()` also registers `JsonMigratableUnionTypeClassifier`, so a C# `union` whose cases are `[JsonMigratable]` types is classified by migration discriminator. Source-generated contexts must name the classifier on the union (`[JsonUnion(TypeClassifier = typeof(JsonMigratableUnionTypeClassifier))]`); see [polymorphism.md](polymorphism.md). The `[JsonPolymorphic]` limitation is unchanged.

## Checklist

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0;net11.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<RootNamespace>Egil.SystemTextJson.Migration.Samples</RootNamespace>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0;net11.0</TargetFrameworks>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA1707</NoWarn>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PackagePath
[string]$PackagePath,
[ValidateSet('net8.0', 'net9.0', 'net10.0', 'net11.0')]
[string]$TargetFramework = 'net10.0'
)

Set-StrictMode -Version Latest
Expand Down Expand Up @@ -59,7 +61,8 @@ try {
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>$TargetFramework</TargetFramework>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RestoreSources>$($package.Directory.FullName)</RestoreSources>
</PropertyGroup>
Expand Down Expand Up @@ -92,7 +95,8 @@ try {
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>$TargetFramework</TargetFramework>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RestoreSources>$($package.Directory.FullName)</RestoreSources>
</PropertyGroup>
Expand Down Expand Up @@ -137,7 +141,8 @@ public sealed class Source
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>$TargetFramework</TargetFramework>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RestoreSources>$($package.Directory.FullName)</RestoreSources>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<#
.SYNOPSIS
Verifies trimming/AOT diagnostics from a packed migration library on both supported frameworks.
Verifies trimming/AOT diagnostics from a packed migration library on all supported frameworks.
.DESCRIPTION
Uses isolated consumers and package caches, retains logs and a package-hash results manifest,
and executes only the supported untrimmed consumers. -Publish adds rooted trim and NativeAOT
Expand All @@ -18,7 +18,7 @@ param(
[string]$RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier,
[switch]$Publish,
[switch]$NativeCompileOnly,
[ValidateSet('net10.0', 'net11.0')] [string[]]$TargetFrameworks = @('net10.0', 'net11.0')
[ValidateSet('net8.0', 'net9.0', 'net10.0', 'net11.0')] [string[]]$TargetFrameworks = @('net8.0', 'net9.0', 'net10.0', 'net11.0')
)

Set-StrictMode -Version Latest
Expand Down Expand Up @@ -140,6 +140,7 @@ foreach ($framework in $TargetFrameworks) {
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>$framework</TargetFramework>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<Nullable>enable</Nullable>
<EnableTrimAnalyzer>$trim</EnableTrimAnalyzer>
<EnableAotAnalyzer>$aot</EnableAotAnalyzer>
Expand Down Expand Up @@ -168,7 +169,7 @@ foreach ($framework in $TargetFrameworks) {
if ($mode -eq 'aot' -and $NativeCompileOnly) {
# Run the actual NativeAOT compiler and its analysis, but do not invoke a linker.
# This is useful on Windows without the C++ workload; CI uses the full publish.
# The .NET 11 compiler consumes ResolvedFileToPublish; .NET 10 consumes copy-local assets.
# The .NET 11 compiler consumes ResolvedFileToPublish; earlier targets consume copy-local assets.
$targets = if ($framework -eq 'net11.0') { 'Build;ComputeResolvedFilesToPublishList;IlcCompile' } else { 'Build;_ComputeResolvedCopyLocalPublishAssets;_ComputeAssembliesToCompileToNative;IlcCompile' }
$output = Invoke-DotNet @('msbuild', $project, "-t:$targets", '-p:Configuration=Release', '-p:SelfContained=true', "-p:RuntimeIdentifier=$RuntimeIdentifier", '-p:IlcUseEnvironmentalTools=true') (Join-Path $directory 'native-compile.log')
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0;net11.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>true</IsPackable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,5 +169,9 @@ public static bool IsTokenCompatible(JsonTokenType tokenType, SourceValueShape s
/// report their real element type.
/// </summary>
public static Type GetValueType(JsonTypeInfo collectionTypeInfo)
#if NET8_0
=> StjInternals.GetElementType(collectionTypeInfo) ?? typeof(object);
#else
=> collectionTypeInfo.ElementType ?? typeof(object);
#endif
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
#if NET8_0
using System.Text.Json.Serialization.Metadata;
#endif

namespace Egil.SystemTextJson.Migration.Migrations;

/// <summary>
/// Provides access to internal System.Text.Json members that are necessary
/// to bypass the <c>GetReaderScopedToNextValue</c> overhead in
/// <c>JsonSerializer.Deserialize</c>.
/// Provides access to internal System.Text.Json collection metadata and converter
/// entry points, including a path that bypasses the <c>GetReaderScopedToNextValue</c>
/// overhead in <c>JsonSerializer.Deserialize</c>.
///
/// When <c>JsonSerializer.Deserialize(ref reader, typeInfo)</c> is called, it
/// internally copies the reader, skips the entire JSON value to measure its span,
Expand All @@ -19,15 +22,30 @@ namespace Egil.SystemTextJson.Migration.Migrations;
/// <c>JsonResumableConverter&lt;T&gt;.Read</c> path which creates a <c>ReadStack</c>
/// and calls <c>TryRead</c> directly — no scoped reader, no double-parse.
///
/// Targeted internal APIs (System.Text.Json, .NET 10 and .NET 11; the signature was verified
/// unchanged against the v11.0.0-rc.1 source):
/// Targeted internal APIs:
/// - <c>JsonConverter.ReadAsObject(ref Utf8JsonReader, Type, JsonSerializerOptions)</c>
/// on .NET 8, .NET 9, .NET 10 and .NET 11; the signature was verified unchanged
/// through the v11.0.0-rc.1 source.
/// - <c>JsonTypeInfo.get_ElementType()</c> on .NET 8 only; this property is internal
/// in System.Text.Json 8 and public from System.Text.Json 9 onward.
///
/// A signature change in a future runtime surfaces as a <see cref="MissingMethodException"/>
/// on first use; the test suite exercises this call on every target framework.
/// on first use; the test suite exercises each accessor on its applicable target frameworks.
/// </summary>
internal static class StjInternals
{
#if NET8_0
// ElementType is internal in STJ 8 and public from STJ 9 onward. Read the actual
// resolved contract rather than inferring its element from CLR interfaces: custom
// metadata can select a different collection contract than the default resolver.
// Keeping this accessor in the net8.0 asset also avoids upgrading JSON inside hosts
// that already loaded STJ 8 (for example, AutoCAD/Civil 3D).
// TODO: Remove this accessor when the net8.0 target is retired.
// https://github.com/dotnet/runtime/blob/v8.0.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_ElementType")]
internal static extern Type? GetElementType(JsonTypeInfo @this);
#endif

/// <summary>
/// Calls the internal <c>ReadAsObject</c> method on a <see cref="JsonConverter"/>.
/// This dispatches to <c>JsonConverter&lt;T&gt;.ReadAsObject</c> which calls
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0;net11.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<IsTestProject>true</IsTestProject>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
Expand Down
Loading
Loading