Refused JSON contracts built by reflection and checked all of them in the trimmed build (#1152)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-03 18:29:50 +01:00
1 parent f864359290
commit 0b42c0288e
23 files changed
+535 -99

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AIStudio.TrimSmokeTest;
/// <summary>
/// Finds the options with which AI Studio reads and writes JSON.
/// </summary>
/// <remarks>
/// AI Studio keeps each kind of options in a static field or property, next to the code which uses them, so
/// these are the options which reach System.Text.Json. A list written by hand would miss the next new one.
/// The tests of the app compile this file, too, see Tests.csproj, so that both check the same options.
/// </remarks>
public static class AppJsonOptions
{
private const BindingFlags STATIC_MEMBERS = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
/// <summary>
/// Returns the options of every static field and property of AI Studio which holds some.
/// </summary>
/// <remarks>
/// Reading a member runs the type initializer of its type. Hence, the static state which the start of
/// the app fills has to be there before, Program.LOGGER_FACTORY above all.
/// </remarks>
/// <returns>The options by the name of their type and member, e.g., SettingsManager.JSON_OPTIONS.</returns>
public static IReadOnlyDictionary<string, JsonSerializerOptions> FindAll()
{
var found = new SortedDictionary<string, JsonSerializerOptions>(StringComparer.Ordinal);
//
// The Program of AI Studio, not the one of the trim smoke test, which is closer from here. Only within
// the tests, which compile this file, too, the qualifier is redundant, and Rider reads it there:
//
// ReSharper disable once RedundantNameQualifier
foreach (var type in typeof(AIStudio.Program).Assembly.GetTypes())
{
//
// A context keeps options of its own for its Default instance, which never reach System.Text.Json
// directly. The static members of an open generic type hold no value at all:
//
if (type.ContainsGenericParameters || type.IsSubclassOf(typeof(JsonSerializerContext)))
continue;
//
// The field behind a static auto-property is found as the property already:
//
foreach (var field in type.GetFields(STATIC_MEMBERS))
if (field.FieldType == typeof(JsonSerializerOptions) && !field.IsDefined(typeof(CompilerGeneratedAttribute)) && field.GetValue(null) is JsonSerializerOptions options)
found.Add($"{type.Name}.{field.Name}", options);
foreach (var property in type.GetProperties(STATIC_MEMBERS))
if (property.PropertyType == typeof(JsonSerializerOptions) && property.GetMethod is not null && property.GetValue(null) is JsonSerializerOptions options)
found.Add($"{type.Name}.{property.Name}", options);
}
return found;
}
}
+4 -1
View File
@@ -45,7 +45,10 @@ public static class JsonContractWalker
return;
}
foreach (var property in typeInfo.Properties)
//
// System.Text.Json never meets the type of an ignored property, e.g., one marked with JsonIgnore:
//
foreach (var property in typeInfo.Properties.Where(property => property is not { Get: null, Set: null, AssociatedParameter: null }))
Visit(property.PropertyType, $"{path} → {property.Name}", options, visited, failures);
if (typeInfo.KeyType is { } keyType)
+26 -53
View File
@@ -1,65 +1,38 @@
using System.Text.Json;
using AIStudio.Agents;
using AIStudio.Agents.AssistantAudit;
using AIStudio.Assistants.Builder;
using AIStudio.Assistants.PromptOptimizer;
using AIStudio.Assistants.SWOTAnalysis;
using AIStudio.Assistants.VisualBriefing;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Settings.DataModel.PreviousModels;
using AIStudio.Tools;
using AIStudio.Tools.DataBackup;
using System.Text.Json.Serialization;
namespace AIStudio.TrimSmokeTest;
/// <summary>
/// The types which AI Studio reads or writes as JSON on their own, not only as part of another type.
/// The types which AI Studio reads or writes as JSON, each with the options it uses for them.
/// </summary>
/// <remarks>
/// Whatever these types hold, i.e., the types of their properties, the elements of their collections, and
/// their derived types, gets checked along with them, so only the outermost types belong here. Add a type
/// whenever AI Studio starts to store a new kind of file or to read a new kind of model answer.<br/><br/>
///
/// The answers of models are read with options of their own, which are private and only change names and
/// leniency. The default options build the same contracts for the types of .NET they hold. A type which is
/// private to its parser, such as the DTO of a slide deck, is out of reach here.
/// The options of AI Studio each name the context which knows their types, so the types registered in that
/// context are exactly those which System.Text.Json gets to read or write with these options. Whatever these
/// types hold, i.e., the types of their properties, the elements of their collections, and their derived
/// types, gets checked along with them. A new type, or new options, need nothing here: they are found
/// through their context.
/// </remarks>
public static class JsonRoots
{
public static readonly IReadOnlyList<JsonRoot> ALL =
[
// Chats, in the workspaces, among the temporary chats, and in chat archives:
new(typeof(ChatThread), WorkspaceBehaviour.JSON_OPTIONS),
/// <summary>
/// Returns every type registered in a context of the options of AI Studio, together with these options.
/// </summary>
/// <remarks>
/// Options which several members share are checked once. Like AppJsonOptions.FindAll, this needs the
/// static state of the app to be filled before.
/// </remarks>
public static IReadOnlyList<JsonRoot> FindAll() => AppJsonOptions.FindAll().Values
.Distinct(ReferenceEqualityComparer.Instance)
.Cast<JsonSerializerOptions>()
.SelectMany(options => options.TypeInfoResolverChain
.OfType<JsonSerializerContext>()
.SelectMany(RegisteredTypes)
.Select(type => new JsonRoot(type, options)))
.ToList();
// The settings, and their older versions, which the migrations still read:
new(typeof(Data), SettingsManager.JSON_OPTIONS),
new(typeof(DataV1V3), SettingsManager.JSON_OPTIONS),
new(typeof(DataV4), SettingsManager.JSON_OPTIONS),
new(typeof(DataV5), SettingsManager.JSON_OPTIONS),
// The projects of the Visual Briefing assistant:
new(typeof(VisualBriefingManifest), VisualBriefingJson.Persistence),
new(typeof(VisualBriefingBuildRecord), VisualBriefingJson.Persistence),
new(typeof(VisualBriefingEvidenceArtifact), VisualBriefingJson.Persistence),
new(typeof(VisualBriefingPlanArtifact), VisualBriefingJson.Persistence),
new(typeof(VisualBriefingContentArtifact), VisualBriefingJson.Persistence),
new(typeof(VisualBriefingPresentationArtifact), VisualBriefingJson.Persistence),
// Backups and their restore:
new(typeof(DataBackupManifest), DataBackupFormat.JSON_OPTIONS),
new(typeof(DataRestorePlan), DataBackupFormat.JSON_OPTIONS),
new(typeof(DataRestoreReport), DataBackupFormat.JSON_OPTIONS),
// Answers of models:
new(typeof(LuaResponse), JsonSerializerOptions.Default),
new(typeof(LauncherTextsResponse), JsonSerializerOptions.Default),
new(typeof(PromptOptimizationResult), JsonSerializerOptions.Default),
new(typeof(RetrievalContextValidationResult), JsonSerializerOptions.Default),
new(typeof(AssistantAuditResult), JsonSerializerOptions.Default),
new(typeof(SwotCategoryAnalysisResult), JsonSerializerOptions.Default),
new(typeof(SwotModerationResult), JsonSerializerOptions.Default),
];
private static IEnumerable<Type> RegisteredTypes(JsonSerializerContext context) => context.GetType()
.GetCustomAttributesData()
.Where(attribute => attribute.AttributeType == typeof(JsonSerializableAttribute))
.Select(attribute => (Type)attribute.ConstructorArguments[0].Value!);
}
+10 -2
View File
@@ -15,14 +15,22 @@ public static class Program
//
AIStudio.Program.LOGGER_FACTORY = NullLoggerFactory.Instance;
var roots = JsonRoots.FindAll();
var failures = new List<string>();
foreach (var root in JsonRoots.ALL)
//
// Finding no options at all means looking in the wrong place, not a build without JSON:
//
if (roots.Count is 0)
failures.Add("Found no JSON options of AI Studio, see AppJsonOptions.FindAll, so nothing got checked.");
foreach (var root in roots)
failures.AddRange(JsonContractWalker.FindBrokenContracts(root));
failures.AddRange(JsonRoundTrips.FindLosses());
if (failures.Count is 0)
{
Console.WriteLine($"- All {JsonRoots.ALL.Count} JSON roots can be read and written.");
Console.WriteLine($"- All {roots.Count} JSON roots can be read and written.");
return 0;
}
+10
View File
@@ -21,6 +21,16 @@
<Import Project="..\Trimming.props" />
<!--
AI Studio reaches nearly all of its code from its start, and with it the assemblies it depends on. This
program starts nowhere near that, so the trimmer would remove, e.g., the Blazor server and SQLite, and the
types of the app which use them could not even be loaded, let alone their options be read. Rooting the
app keeps what the app itself keeps, while code of .NET which only reflection reaches is still removed.
-->
<ItemGroup>
<TrimmerRootAssembly Include="mindworkAIStudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MindWork AI Studio\MindWork AI Studio.csproj" />
</ItemGroup>