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
22 changes: 22 additions & 0 deletions .github/workflows/egil-systemtextjson-migration-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ jobs:
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)"

- name: Install NativeAOT prerequisites
run: |
sudo apt-get update
sudo apt-get install -y clang zlib1g-dev

- name: Verify packaged trimming and NativeAOT diagnostics
working-directory: Egil.SystemTextJson.Migration
run: pwsh -NoProfile -File ./scripts/verify-trimming-package.ps1 -PackagePath "$(find ${{ env.NuGetDirectory }} -maxdepth 1 -name '*.nupkg' -print -quit)" -Publish -RuntimeIdentifier linux-x64

- name: Retain diagnostic evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: trimming-diagnostics
if-no-files-found: ignore
retention-days: 7
path: |
Egil.SystemTextJson.Migration/artifacts/trimming/**/*.log
Egil.SystemTextJson.Migration/artifacts/trimming/**/Program.cs
Egil.SystemTextJson.Migration/artifacts/trimming/**/Consumer.csproj
Egil.SystemTextJson.Migration/artifacts/trimming/**/results.json

- uses: actions/upload-artifact@v4
with:
name: nuget
Expand Down
26 changes: 24 additions & 2 deletions Egil.SystemTextJson.Migration/docs/recipes/aot-source-gen.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
# AOT & Source Generation

Migration currently requires **untrimmed, non-NativeAOT deployment**, including when using a source-generated `JsonSerializerContext` or generic migrator registration. Configure the application with:

```xml
<PropertyGroup>
<PublishTrimmed>false</PublishTrimmed>
<PublishAot>false</PublishAot>
</PropertyGroup>
```

## Build and publish diagnostics

Both `AddJsonMigrationSupport` overloads and all migrator registration methods declare `RequiresUnreferencedCode` and `RequiresDynamicCode`. On .NET 11, the `JsonMigratableUnionTypeClassifier` constructor also declares these requirements. Its overrides keep the System.Text.Json base contract unchanged.

- Trim analysis reports **IL2026** at the consumer call: migration discovery requires members trimming may remove; publish with `PublishTrimmed=false` and `PublishAot=false`. A source-generated `JsonSerializerContext` does not remove this requirement.
- AOT analysis reports **IL3050** at the consumer call: migration creates generic converters and invokers at runtime; publish with `PublishAot=false`. Applicable trimming diagnostics are also reported.

`PublishTrimmed=true` enables trim analysis; it does **not** by itself enable AOT analysis or guarantee IL3050. `PublishAot=true` enables AOT analysis. Analysis can also be requested explicitly through `EnableTrimAnalyzer` and `EnableAotAnalyzer`. Ordinary builds without the relevant analyzers are not guaranteed to report either warning. If analyzers remain explicitly enabled for an untrimmed application, their warnings still describe these API requirements.

The diagnostics include the corrective action and an optional link to this recipe. Do not treat suppressing a warning as establishing compatibility. See [issue #256](https://github.com/egil/framework/issues/256) for the diagnostic contract and the separately scoped AOT-safe registration follow-up.

## Using with source-generated `JsonSerializerContext`

The library is compatible with System.Text.Json source generation. Register both old (source) and current (target) types in your `JsonSerializerContext`:
The library works with System.Text.Json source generation for serialization metadata, subject to the [deployment requirements above](#aot--source-generation). Register both old (source) and current (target) types in your `JsonSerializerContext`:

<!-- snippet: source_gen_context -->
<a id='snippet-source_gen_context'></a>
Expand Down Expand Up @@ -49,4 +69,6 @@ Discovery does. When a converter is created for a `[JsonMigratable]` type — on

## NativeAOT and trimming

**Publishing with `PublishAot` or `PublishTrimmed` is not supported yet.** Source generation removes STJ's own reflection, but the trimmer also removes the `IMigrateFrom<,>` interface implementations and parameterless constructors that discovery depends on, so static migrators are silently absent and external migrators cannot be activated. A trimmed application deserializes current payloads correctly but fails old payloads with "No migrator was found for discriminator '...'". Trim-safe explicit registration is tracked as a follow-up; until it ships, run migration-enabled applications untrimmed.
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.
137 changes: 137 additions & 0 deletions Egil.SystemTextJson.Migration/scripts/trimming-consumer/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Egil.SystemTextJson.Migration;

// verify-trimming-package.ps1 reads diagnostic markers to assert real source locations.
// Keep each marker immediately above its call or declaration, with no intervening lines.
Calls.Setup();
Calls.Provider();
Calls.Generated();
Calls.RegisteredGenerated();
Calls.Register();
Calls.Configure();
#if NET11_0_OR_GREATER
Calls.Union();
#endif

static class Calls
{
public static void Setup()
{
// diagnostic: Setup
new JsonSerializerOptions().AddJsonMigrationSupport();
}
public static void Provider()
{
// diagnostic: Provider
new JsonSerializerOptions().AddJsonMigrationSupport(new Services());
}
public static void Generated()
{
var options = new JsonSerializerOptions { TypeInfoResolver = Context.Default };
// diagnostic: Generated
options.AddJsonMigrationSupport();
// diagnostic: GeneratedProvider
new JsonSerializerOptions { TypeInfoResolver = Context.Default }.AddJsonMigrationSupport(new Services());
var result = JsonSerializer.Deserialize("{\"$type\":\"old\",\"Value\":42}", options.GetTypeInfo(typeof(Current))) as Current;
if (result?.Value != 42)
{
throw new Exception("Old payload migration failed.");
}
}

public static void RegisteredGenerated()
{
var options = new JsonSerializerOptions { TypeInfoResolver = Context.Default };
// diagnostic: RegisteredGenerated
options.AddJsonMigrationSupport(new Services(), builder =>
{
// diagnostic: GeneratedGeneric
builder.RegisterMigrator<Old, External, ExternalMigrator>();
});
var result = JsonSerializer.Deserialize("{\"$type\":\"old\",\"Value\":42}", options.GetTypeInfo(typeof(External))) as External;
if (result?.Value != 43)
{
throw new Exception("Service-provider migration with generated metadata failed.");
}
}
public static void Register()
{
// diagnostic: Generic
new JsonMigrationBuilder().RegisterMigrator<Old, Current, Migrator>();
// diagnostic: Discovered
new JsonMigrationBuilder().RegisterMigrator<Migrator>();
// diagnostic: Assembly
new JsonMigrationBuilder().RegisterMigratorsFromAssembly(typeof(Migrator).Assembly);
// diagnostic: Assemblies
new JsonMigrationBuilder().RegisterMigratorsFromAssemblies(typeof(Migrator).Assembly);
}

public static void Configure()
{
// Configuration alone performs no discovery and must not acquire blanket Requires* warnings.
new JsonMigrationBuilder()
.UseServiceProvider(new Services())
.GetTypeDiscriminatorFrom<JsonMigratableAttribute>(attribute => attribute.TypeDiscriminator)
.SetTypeDiscriminatorPropertyName("$type")
.SetMigrationFailureHandling(JsonMigrationFailureHandling.ThrowJsonException);
}
#if NET11_0_OR_GREATER
public static void Union()
{
// diagnostic: Classifier
_ = new JsonMigratableUnionTypeClassifier();
var options = new JsonSerializerOptions { TypeInfoResolver = Context.Default };
// diagnostic: UnionSetup
options.AddJsonMigrationSupport();
_ = JsonSerializer.Deserialize("{\"$type\":\"old\",\"Value\":42}", options.GetTypeInfo(typeof(Result)));
}
#endif
}
sealed class Services : IServiceProvider
{
public object? GetService(Type type) => type == typeof(ExternalMigrator) ? new ExternalMigrator(1) : null;
}
[JsonMigratable(TypeDiscriminator = "old")]
public record Old(int Value);
[JsonMigratable(TypeDiscriminator = "current")]
public record Current(int Value) : IMigrateFrom<Old, Current>
{
public static bool TryMigrateFrom(Old source, out Current result)
{
result = new(source.Value);
return true;
}
}
public sealed class Migrator : IMigrate<Old, Current>
{
public bool TryMigrateFrom(Old source, out Current result)
{
result = new(source.Value);
return true;
}
}

[JsonMigratable(TypeDiscriminator = "external")]
public record External(int Value);

public sealed class ExternalMigrator(int increment) : IMigrate<Old, External>
{
public bool TryMigrateFrom(Old source, out External result)
{
result = new(source.Value + increment);
return true;
}
}
#if NET11_0_OR_GREATER
// diagnostic: UnionAttribute
[JsonUnion(TypeClassifier = typeof(JsonMigratableUnionTypeClassifier))]
public union Result(Current, string);
[JsonSerializable(typeof(Result))]
#endif
[JsonSerializable(typeof(Old))]
[JsonSerializable(typeof(Current))]
[JsonSerializable(typeof(External))]
[JsonSerializable(typeof(string))]
internal partial class Context : JsonSerializerContext;
Loading
Loading