diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataBackupFormat.cs b/app/MindWork AI Studio/Tools/DataBackup/DataBackupFormat.cs index 60db85142..f487a68b4 100644 --- a/app/MindWork AI Studio/Tools/DataBackup/DataBackupFormat.cs +++ b/app/MindWork AI Studio/Tools/DataBackup/DataBackupFormat.cs @@ -51,12 +51,13 @@ public static class DataBackupFormat /// How the manifest and the plan of a restore are written: readable for anybody who opens them, /// and for the runtime, which applies the plan. /// - public static readonly JsonSerializerOptions JSON_OPTIONS = new() + public static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = { new JsonStringEnumConverter() }, - }; + TypeInfoResolver = DataBackupJsonContext.Default, + }.AsReadOnly(); /// /// File types which are compressed already. Compressing them once more costs time and makes the diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataBackupJsonContext.cs b/app/MindWork AI Studio/Tools/DataBackup/DataBackupJsonContext.cs new file mode 100644 index 000000000..1289f9818 --- /dev/null +++ b/app/MindWork AI Studio/Tools/DataBackup/DataBackupJsonContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools.DataBackup; + +/// +/// The contracts with which System.Text.Json reads and writes the manifest of a backup and the plan and report of +/// a restore, built at compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(DataBackupManifest))] +[JsonSerializable(typeof(DataRestorePlan))] +[JsonSerializable(typeof(DataRestoreReport))] +internal sealed partial class DataBackupJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/ProvenanceStore/ProvenanceJsonContext.cs b/app/MindWork AI Studio/Tools/Databases/ProvenanceStore/ProvenanceJsonContext.cs new file mode 100644 index 000000000..4831a6169 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/ProvenanceStore/ProvenanceJsonContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools.Databases.ProvenanceStore; + +/// +/// The contracts with which System.Text.Json reads and writes the content around a passage, built at compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(List))] +internal sealed partial class ProvenanceJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/ProvenanceStore/SqliteProvenanceStoreClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/ProvenanceStore/SqliteProvenanceStoreClientImplementation.cs index b7e198555..2414d0f25 100644 --- a/app/MindWork AI Studio/Tools/Databases/ProvenanceStore/SqliteProvenanceStoreClientImplementation.cs +++ b/app/MindWork AI Studio/Tools/Databases/ProvenanceStore/SqliteProvenanceStoreClientImplementation.cs @@ -39,7 +39,11 @@ public sealed class SqliteProvenanceStoreClientImplementation(string name, strin /// Writes the content around a passage the way it reads: the default encoder would turn every /// character outside ASCII into an escape, cf. ToolExecutionResult.MODEL_CONTENT_OPTIONS. /// - internal static readonly JsonSerializerOptions JSON_OPTIONS = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + TypeInfoResolver = ProvenanceJsonContext.Default, + }.AsReadOnly(); private readonly string databasePath = databasePath; private readonly string lastCleanupPath = Path.Join(basePath, LAST_CLEANUP_FILENAME); @@ -351,7 +355,7 @@ public sealed class SqliteProvenanceStoreClientImplementation(string name, strin try { - return JsonSerializer.Deserialize>(row.SurroundingContent) ?? []; + return JsonSerializer.Deserialize>(row.SurroundingContent, JSON_OPTIONS) ?? []; } catch (JsonException exception) { diff --git a/app/Tests/Serialization/JsonCoverageTests.cs b/app/Tests/Serialization/JsonCoverageTests.cs new file mode 100644 index 000000000..b900e49f3 --- /dev/null +++ b/app/Tests/Serialization/JsonCoverageTests.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tests.Serialization; + +/// +/// Checks that every contract AI Studio uses for its JSON comes from source generation, never from reflection. +/// +/// +/// A type which no context of the options knows makes System.Text.Json throw, unless the options fall back to +/// reflection. Such a fallback hides the missing type until a trimmed build removes what reflection needs, and +/// only a published build shows it then. +/// +[TestFixture] +public sealed class JsonCoverageTests +{ + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))] + public void EveryContractComesFromSourceGeneration(JsonTestRoot root) + { + var typesWithoutGeneratedContract = JsonTypeGraph.Collect(root.Type, root.Options) + .Where(type => root.Options.GetTypeInfo(type).OriginatingResolver is not JsonSerializerContext) + .Select(JsonContractDescription.TypeName); + + Assert.That(typesWithoutGeneratedContract, Is.Empty); + } +} \ No newline at end of file