From 0b42c0288e10f4424ac3ab6571687e8330c29af9 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 3 Nov 2026 18:29:50 +0100 Subject: [PATCH] Refused JSON contracts built by reflection and checked all of them in the trimmed build (#1152) --- app/AGENTS.md | 1 + app/Build/Commands/VerifyCommand.cs | 9 ++ .../Components/JsonTreeView.razor.cs | 19 ++- .../ToolCallingSystem/ToolExecutionResult.cs | 6 +- .../ToolExecutionResultJsonContext.cs | 15 ++ .../wwwroot/changelog/v26.11.1.md | 1 + .../AnalyzerReleases.Shipped.md | 1 + .../SourceCodeRules/Identifier.cs | 1 + .../JsonSourceGenerationAnalyzer.cs | 146 ++++++++++++++++++ .../JsonSourceGenerationAnalyzerTests.cs | 106 +++++++++++++ app/Tests/Components/JsonTreeViewTests.cs | 46 ++++++ app/Tests/Provider/ModelListMetadataTests.cs | 24 ++- .../Provider/SelfHostedModelListTests.cs | 7 +- .../Serialization/JsonOptionsResolverTests.cs | 49 ++++++ app/Tests/Tests.csproj | 7 + app/Tests/Tools/FileTypesTests.cs | 3 +- app/Tests/Tools/ShareFileResponseTests.cs | 17 +- app/TrimSmokeTest/AppJsonOptions.cs | 60 +++++++ app/TrimSmokeTest/JsonContractWalker.cs | 5 +- app/TrimSmokeTest/JsonRoots.cs | 79 ++++------ app/TrimSmokeTest/Program.cs | 12 +- app/TrimSmokeTest/TrimSmokeTest.csproj | 10 ++ app/Trimming.props | 10 +- 23 files changed, 535 insertions(+), 99 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResultJsonContext.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/JsonSourceGenerationAnalyzer.cs create mode 100644 app/Tests/Analyzers/JsonSourceGenerationAnalyzerTests.cs create mode 100644 app/Tests/Components/JsonTreeViewTests.cs create mode 100644 app/Tests/Serialization/JsonOptionsResolverTests.cs create mode 100644 app/TrimSmokeTest/AppJsonOptions.cs diff --git a/app/AGENTS.md b/app/AGENTS.md index 0d8f97b30..e88ddd5c6 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -34,6 +34,7 @@ Key structure: - **New files and folders in the data or configuration directory** - Whoever adds a file or folder directly to the data or configuration directory, or below `plugins/` or `databases/` one which needs a decision of its own, lists it in `DataBackupContents.ENTRIES`: as part of a backup, or with the reason a backup leaves it out. A backup takes along only what is listed as included, and a restore replaces only that. An entry included in a backup also goes into `REPLACEABLE_ELEMENTS` in `runtime/src/data_restore.rs`; otherwise the runtime refuses to restore it. The folder panel on the information page shows the list to users who back up these folders with a tool of their own. - **Sending content on** - Content which leaves one place for another, such as the result of an assistant sent to another assistant or a text sent into the chat, travels as a `TextLaunchPayload` through `AssistantLaunchService`, together with the restrictions of where it was written (`ChatRestrictions`, see `ChatThread.CaptureRestrictions`). Never hand over a bare string: the destination would let any provider or tool see content which its source kept from them. Build the payload with `AssistantLaunchService.CreateTextPayloadAsync`, which filters what an AI wrote for prompt injections before it reaches the next model as a message of the user. An assistant which allows sending to it overrides `AssistantBase.ApplyTextLaunchPayload`, and `SendToReceiverTests` checks that every one does; the restrictions themselves are applied centrally in `AssistantBase`. - **JSON in model answers** - Read the JSON part of a model answer with `ModelAnswerJson`, never with a scanner of your own. It skips the prose and code fences around the JSON but checks nothing, so broken JSON reaches the deserializer unchanged and its message can go back to the model. +- **JSON contracts** - The contracts of all JSON which AI Studio reads or writes are generated while compiling. Each kind of options names the `JsonSerializerContext` which knows its types, with `GenerationMode = Metadata`, as its `TypeInfoResolver` and ends with `.AsReadOnly()`; only `VisualBriefingJson.Canonical` leaves that out, see MWAIS0010. A new JSON type goes into the context of the options it is read or written with, and into `JsonTestRoots` in `app/Tests/Serialization/`, whose contract, defaults, and coverage tests then check it. Give JSON types `set` instead of `init`, because the source generator of .NET 10 drops the initializer of an `init` property which the JSON leaves out, and write a record or a `JsonObject` instead of an anonymous type. Never call System.Text.Json without options, nor with `JsonSerializerOptions.Default`, `.Web`, or a `DefaultJsonTypeInfoResolver`: each of them builds the contracts by reflection, whose code a published build may lack. MWAIS0015 refuses them while compiling, `JsonOptionsResolverTests` checks the resolvers of all options of the app, and the trim smoke test of `dotnet run verify` builds the contract of every type of their contexts in a trimmed build. Reflection itself stays switched on, because Blazor Server needs it for JSON of its own, see `Trimming.props`. - **Long menus** - MudBlazor keeps a `MudSelect` inside the window by itself, but never a `MudMenu`: a menu taller than the window runs past its edge, and its lower entries cannot be reached. Give every `MudMenu` whose entries come from data, such as the assistants or the profiles, `MaxHeight="@MenuDefaults.MAX_HEIGHT"`, so that it scrolls. - **Growing popovers** - Since version 9, MudBlazor places an open popover again whenever its content changes size. Content which unfolds later, such as a `MudCollapse` or an `ExpansionPanel` inside a `MudPopover`, therefore stays inside the window and needs no code of its own. - **Encryption** - Initialized before Rust service is marked ready diff --git a/app/Build/Commands/VerifyCommand.cs b/app/Build/Commands/VerifyCommand.cs index a8b37992a..05007f172 100644 --- a/app/Build/Commands/VerifyCommand.cs +++ b/app/Build/Commands/VerifyCommand.cs @@ -94,6 +94,15 @@ public sealed class VerifyCommand var directory = Environment.GetTrimSmokeTestDirectory(); var publishDirectory = Path.Combine(directory, "bin", "verify"); + + // + // Publishing leaves the files of an earlier run in place, among them copies of assemblies which the trimmer + // now removes completely. The dependency manifest still lists such an assembly, so a leftover copy can be + // loaded in its place and hide exactly what the smoke test is there to find: + // + if (Directory.Exists(publishDirectory)) + Directory.Delete(publishDirectory, recursive: true); + var publishExitCode = await CommandRunner.RunAsync(directory, "dotnet", $"publish --configuration release --runtime {rid.AsMicrosoftRid()} --output \"{publishDirectory}\" --nologo"); if (publishExitCode is not 0) return publishExitCode; diff --git a/app/MindWork AI Studio/Components/JsonTreeView.razor.cs b/app/MindWork AI Studio/Components/JsonTreeView.razor.cs index a7e79070b..d5cf78f6e 100644 --- a/app/MindWork AI Studio/Components/JsonTreeView.razor.cs +++ b/app/MindWork AI Studio/Components/JsonTreeView.razor.cs @@ -1,6 +1,7 @@ -using System.Text.Json; using System.Text.Json.Nodes; +using AIStudio.Tools.ToolCallingSystem; + using Microsoft.AspNetCore.Components; namespace AIStudio.Components; @@ -37,7 +38,7 @@ public partial class JsonTreeView : ComponentBase private static List> CreateChildren(JsonNode? value) => value switch { JsonObject jsonObject => jsonObject - .Select(property => CreateTreeItem(JsonSerializer.Serialize(property.Key), property.Value)) + .Select(property => CreateTreeItem(FormatKey(property.Key), property.Value)) .ToList(), JsonArray jsonArray => jsonArray .Select((item, index) => CreateTreeItem($"[{index}]", item)) @@ -45,14 +46,24 @@ public partial class JsonTreeView : ComponentBase _ => [], }; - private static string FormatValue(JsonNode? value) => value switch + internal static string FormatKey(string key) => JsonValue.Create(key).ToJsonString(ToolExecutionResult.MODEL_CONTENT_OPTIONS); + + /// + /// Writes a value as JSON, objects and arrays only as a placeholder. + /// + /// + /// The tree shows the result of a tool, so it writes like the model reads it, see + /// ToolExecutionResult.MODEL_CONTENT_OPTIONS. The default options would escape every umlaut and + /// every quotation mark for a web page, although Blazor encodes the text for the page anyway. + /// + internal static string FormatValue(JsonNode? value) => value switch { JsonObject jsonObject when jsonObject.Count == 0 => "{}", JsonObject => "{...}", JsonArray jsonArray when jsonArray.Count == 0 => "[]", JsonArray => "[...]", null => "null", - _ => value.ToJsonString(), + _ => value.ToJsonString(ToolExecutionResult.MODEL_CONTENT_OPTIONS), }; private static string GetIcon(JsonNode? value) => value switch diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs index 4708e8aef..243cf5cbc 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs @@ -21,7 +21,11 @@ public sealed class ToolExecutionResult /// with every umlaut as six characters. The relaxed encoder escapes only what JSON requires. /// // Internal, so a tool which keeps its result within a budget measures exactly what the model gets: - internal static readonly JsonSerializerOptions MODEL_CONTENT_OPTIONS = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + internal static readonly JsonSerializerOptions MODEL_CONTENT_OPTIONS = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + TypeInfoResolver = ToolExecutionResultJsonContext.Default, + }.AsReadOnly(); public string? TextContent { get; init; } diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResultJsonContext.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResultJsonContext.cs new file mode 100644 index 000000000..65915c63d --- /dev/null +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResultJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace AIStudio.Tools.ToolCallingSystem; + +/// +/// The contracts of the options with which a tool result is written for the model, built at compile time. +/// +/// +/// A JSON result is a tree of JSON nodes, which writes itself and needs no contract from the options. The +/// context is there all the same, so that these options can never fall back to reflection either. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(JsonNode))] +internal sealed partial class ToolExecutionResultJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md index 0354b3431..7f3675c5a 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md @@ -17,6 +17,7 @@ - Fixed AI Studio loading none of your organization's configurations when one place, such as the Windows registry, named a configuration with an invalid ID. AI Studio now skips that entry and uses the configurations from the other places. - Fixed chats being stored although you chose "Store chats manually" or "Disable workspaces". AI Studio now stores a chat only when you click "Save chat", or not at all while workspaces are disabled. In both modes, leaving a chat whose answer is still being written asks you first and stops that answer. Chats outside of workspaces that earlier versions stored this way appear among your disappearing chats once you use workspaces again. - Fixed saving a code block as a file, where the last line could end differently from all other lines, for example, on Windows. All lines of the file now end the same way. +- Fixed the result of a tool in the chat showing umlauts, quotation marks, and other special characters as codes instead of the characters themselves. - Upgraded several libraries to improve security. - Upgraded Tauri to v2.12.5. - Upgraded MudBlazor to v9.12.0. diff --git a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md index 308d6c626..716a5fb13 100644 --- a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md +++ b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md @@ -18,3 +18,4 @@ MWAIS0012 | Usage | Error | DirectI18NGetTextAnalyzer MWAIS0013 | Usage | Error | ModelPatternLiteralAnalyzer MWAIS0014 | Usage | Error | MissingInterpolationAnalyzer + MWAIS0015 | Usage | Error | JsonSourceGenerationAnalyzer diff --git a/app/SourceCodeRules/SourceCodeRules/Identifier.cs b/app/SourceCodeRules/SourceCodeRules/Identifier.cs index 007684364..38cf392b3 100644 --- a/app/SourceCodeRules/SourceCodeRules/Identifier.cs +++ b/app/SourceCodeRules/SourceCodeRules/Identifier.cs @@ -16,4 +16,5 @@ public static class Identifier public const string DIRECT_I18N_GET_TEXT_ANALYZER = $"{Tools.ID_PREFIX}0012"; public const string MODEL_PATTERN_LITERAL_ANALYZER = $"{Tools.ID_PREFIX}0013"; public const string MISSING_INTERPOLATION_ANALYZER = $"{Tools.ID_PREFIX}0014"; + public const string JSON_SOURCE_GENERATION_ANALYZER = $"{Tools.ID_PREFIX}0015"; } \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/JsonSourceGenerationAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/JsonSourceGenerationAnalyzer.cs new file mode 100644 index 000000000..c0df67f5b --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/JsonSourceGenerationAnalyzer.cs @@ -0,0 +1,146 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace SourceCodeRules.UsageAnalyzers; + +/// +/// Reports JSON which System.Text.Json would read or write with contracts it builds by reflection. +/// +/// +/// The options of AI Studio name a JsonSerializerContext as their TypeInfoResolver, so that the contracts of +/// their types are generated while compiling. A contract which System.Text.Json builds by reflection instead +/// may lack a property or fail altogether in a published build, since the trimmer removes code which only +/// reflection reaches. Neither a debug build nor the tests show that. Switching reflection off for the whole +/// app is no option, because Blazor Server needs it for JSON of its own. Hence, this analyzer refuses every +/// way into reflection within the code of AI Studio: a call without options, the options +/// JsonSerializerOptions.Default and .Web, a DefaultJsonTypeInfoResolver, and new options without a +/// TypeInfoResolver. +/// +#pragma warning disable RS1038 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +#pragma warning restore RS1038 +public sealed class JsonSourceGenerationAnalyzer : DiagnosticAnalyzer +{ + private const string DIAGNOSTIC_ID = Identifier.JSON_SOURCE_GENERATION_ANALYZER; + private const string CATEGORY = "Usage"; + + private const string JSON_NAMESPACE = "System.Text.Json"; + private const string SERIALIZATION_NAMESPACE = "System.Text.Json.Serialization"; + private const string METADATA_NAMESPACE = "System.Text.Json.Serialization.Metadata"; + private const string OPTIONS_TYPE = "JsonSerializerOptions"; + private const string TYPE_INFO_RESOLVER = "TypeInfoResolver"; + + private const string TITLE = "JSON must use the generated contracts of its options"; + + private const string MESSAGE_FORMAT = "{0} A contract which System.Text.Json builds by reflection may break in a published build, since the trimmer removes what reflection needs"; + + private const string DESCRIPTION = "AI Studio reads and writes JSON with options whose TypeInfoResolver is a JsonSerializerContext, so that every contract is generated while compiling. A call without options, the options JsonSerializerOptions.Default and .Web, a DefaultJsonTypeInfoResolver, and new options without a TypeInfoResolver all make System.Text.Json build the contracts by reflection, which only a published build shows to fail."; + + /// + /// The types whose methods read or write JSON, and which fall back to reflection without options. + /// + private static readonly ImmutableHashSet SERIALIZING_TYPES = + [ + "System.Text.Json.JsonSerializer", + "System.Net.Http.Json.HttpClientJsonExtensions", + "System.Net.Http.Json.HttpContentJsonExtensions", + "System.Net.Http.Json.JsonContent", + ]; + + private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION); + + public override ImmutableArray SupportedDiagnostics => [RULE]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation); + context.RegisterOperationAction(AnalyzePropertyReference, OperationKind.PropertyReference); + context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation); + } + + /// + /// Reports reading or writing JSON without options, or with options which are null. + /// + private static void AnalyzeInvocation(OperationAnalysisContext context) + { + var invocation = (IInvocationOperation)context.Operation; + var method = invocation.TargetMethod; + if (!SERIALIZING_TYPES.Contains(method.ContainingType.ToDisplayString())) + return; + + // + // A method which gets the contract itself, or the context which holds it, needs no options: + // + if (method.Parameters.Any(parameter => IsContract(parameter.Type))) + return; + + var options = invocation.Arguments.FirstOrDefault(argument => Is(argument.Parameter?.Type, JSON_NAMESPACE, OPTIONS_TYPE)); + if (options is not null && options.ArgumentKind is not ArgumentKind.DefaultValue && !IsNull(options.Value)) + return; + + context.ReportDiagnostic(Diagnostic.Create(RULE, invocation.Syntax.GetLocation(), $"{method.ContainingType.Name}.{method.Name} gets no options, so it builds the contract of its type by reflection: pass the options whose context knows the type.")); + } + + /// + /// Reports the shared options of System.Text.Json, which build every contract by reflection. + /// + private static void AnalyzePropertyReference(OperationAnalysisContext context) + { + var reference = (IPropertyReferenceOperation)context.Operation; + if (reference.Property is not { IsStatic: true, Name: "Default" or "Web" } property || !Is(property.ContainingType, JSON_NAMESPACE, OPTIONS_TYPE)) + return; + + context.ReportDiagnostic(Diagnostic.Create(RULE, reference.Syntax.GetLocation(), $"JsonSerializerOptions.{property.Name} builds every contract by reflection: use the options whose context knows the type.")); + } + + /// + /// Reports a resolver which builds contracts by reflection, and new options which name no resolver. + /// + private static void AnalyzeObjectCreation(OperationAnalysisContext context) + { + var creation = (IObjectCreationOperation)context.Operation; + if (IsReflectionResolver(creation.Type)) + { + context.ReportDiagnostic(Diagnostic.Create(RULE, creation.Syntax.GetLocation(), $"{creation.Type!.Name} builds contracts by reflection: register the type in the JsonSerializerContext of its options instead.")); + return; + } + + if (!Is(creation.Type, JSON_NAMESPACE, OPTIONS_TYPE) || NamesTypeInfoResolver(creation.Initializer)) + return; + + context.ReportDiagnostic(Diagnostic.Create(RULE, creation.Syntax.GetLocation(), "These options name no TypeInfoResolver, so System.Text.Json builds their contracts by reflection: set it to the JsonSerializerContext which knows their types, right in the object initializer.")); + } + + /// + /// Whether a parameter takes a contract, as a JsonTypeInfo, or the context which holds the contracts. + /// + private static bool IsContract(ITypeSymbol type) => + Is(type, METADATA_NAMESPACE, "JsonTypeInfo") || + Is(type, SERIALIZATION_NAMESPACE, "JsonSerializerContext"); + + private static bool IsReflectionResolver(ITypeSymbol? type) + { + for (var current = type; current is not null; current = current.BaseType) + if (Is(current, METADATA_NAMESPACE, "DefaultJsonTypeInfoResolver")) + return true; + + return false; + } + + private static bool NamesTypeInfoResolver(IObjectOrCollectionInitializerOperation? initializer) => + initializer is not null && + initializer.Initializers.Any(item => item is ISimpleAssignmentOperation { Target: IPropertyReferenceOperation { Property.Name: TYPE_INFO_RESOLVER } } assignment && !IsNull(assignment.Value)); + + private static bool IsNull(IOperation value) => value.ConstantValue is { HasValue: true, Value: null }; + + private static bool Is(ITypeSymbol? type, string namespaceName, string typeName) => + type is not null && + type.Name == typeName && + type.ContainingNamespace?.ToDisplayString() == namespaceName; +} diff --git a/app/Tests/Analyzers/JsonSourceGenerationAnalyzerTests.cs b/app/Tests/Analyzers/JsonSourceGenerationAnalyzerTests.cs new file mode 100644 index 000000000..e100c1793 --- /dev/null +++ b/app/Tests/Analyzers/JsonSourceGenerationAnalyzerTests.cs @@ -0,0 +1,106 @@ +using AIStudio.Tests.Models.Generation; + +using Microsoft.CodeAnalysis; + +using SourceCodeRules.UsageAnalyzers; + +namespace AIStudio.Tests.Analyzers; + +/// +/// Checks that every way into JSON contracts built by reflection is refused while compiling. +/// +/// +/// The pre-release v26.10.1 failed because a published build lacked what reflection needed for the JSON of +/// AI Studio. Since then, the options of AI Studio name a JsonSerializerContext, whose contracts are generated +/// while compiling. A single call without options brings reflection back, and only a published build shows +/// it, so MWAIS0015 turns such a call into a build error. These tests say which code it refuses and which it +/// leaves alone. +/// +[TestFixture] +public sealed class JsonSourceGenerationAnalyzerTests +{ + [TestCase("JsonSerializer.Serialize(meeting)", "JsonSerializer.Serialize gets no options", TestName = "Writing without options")] + [TestCase("JsonSerializer.Serialize(meeting, options: null)", "JsonSerializer.Serialize gets no options", TestName = "Writing with options which are null")] + [TestCase("JsonSerializer.Deserialize(json)", "JsonSerializer.Deserialize gets no options", TestName = "Reading without options")] + [TestCase("element.Deserialize()", "JsonSerializer.Deserialize gets no options", TestName = "Reading a JSON element without options")] + [TestCase("client.GetFromJsonAsync(\"/meeting\")", "HttpClientJsonExtensions.GetFromJsonAsync gets no options", TestName = "Reading a web answer without options")] + [TestCase("client.PostAsJsonAsync(\"/meeting\", meeting)", "HttpClientJsonExtensions.PostAsJsonAsync gets no options", TestName = "Sending a web request without options")] + [TestCase("content.ReadFromJsonAsync()", "HttpContentJsonExtensions.ReadFromJsonAsync gets no options", TestName = "Reading web content without options")] + [TestCase("JsonContent.Create(meeting)", "JsonContent.Create gets no options", TestName = "Creating web content without options")] + [TestCase("JsonSerializer.Serialize(meeting, JsonSerializerOptions.Default)", "JsonSerializerOptions.Default builds every contract by reflection", TestName = "The default options")] + [TestCase("JsonSerializerOptions.Web", "JsonSerializerOptions.Web builds every contract by reflection", TestName = "The options for the web")] + [TestCase("new DefaultJsonTypeInfoResolver()", "DefaultJsonTypeInfoResolver builds contracts by reflection", TestName = "The resolver which uses reflection")] + [TestCase("new JsonSerializerOptions { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }", "DefaultJsonTypeInfoResolver builds contracts by reflection", TestName = "Options with the resolver which uses reflection")] + [TestCase("new JsonSerializerOptions { WriteIndented = true }", "These options name no TypeInfoResolver", TestName = "Options without a resolver")] + [TestCase("new JsonSerializerOptions(JsonSerializerDefaults.Web)", "These options name no TypeInfoResolver", TestName = "Options for the web without a resolver")] + [TestCase("new JsonSerializerOptions { TypeInfoResolver = null }", "These options name no TypeInfoResolver", TestName = "Options with a resolver which is null")] + public async Task AWayIntoReflectionIsRefused(string expression, string expectedMessage) + { + var reported = await AnalyzeAsync(expression); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reported, Has.Count.EqualTo(1)); + Assert.That(reported.FirstOrDefault()?.Id, Is.EqualTo("MWAIS0015")); + Assert.That(reported.FirstOrDefault()?.GetMessage(), Does.StartWith(expectedMessage)); + } + } + + [Test] + public async Task OptionsCreatedByATargetTypedNewWithoutAResolverAreRefused() + { + var reported = await AnalyzeAsync("OTHER_OPTIONS", "private static readonly JsonSerializerOptions OTHER_OPTIONS = new() { WriteIndented = true };"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reported, Has.Count.EqualTo(1)); + Assert.That(reported.FirstOrDefault()?.GetMessage(), Does.StartWith("These options name no TypeInfoResolver")); + } + } + + [TestCase("JsonSerializer.Serialize(meeting, OPTIONS)", TestName = "Writing with options")] + [TestCase("JsonSerializer.Serialize(meeting, contract)", TestName = "Writing with the contract")] + [TestCase("element.Deserialize(contract)", TestName = "Reading a JSON element with the contract")] + [TestCase("client.GetFromJsonAsync(\"/meeting\", OPTIONS)", TestName = "Reading a web answer with options")] + [TestCase("content.ReadFromJsonAsync(contract)", TestName = "Reading web content with the contract")] + [TestCase("JsonContent.Create(meeting, contract)", TestName = "Creating web content with the contract")] + [TestCase("new JsonSerializerOptions { WriteIndented = true, TypeInfoResolver = resolver }", TestName = "Options with a resolver")] + [TestCase("new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = resolver }", TestName = "Options for the web with a resolver")] + [TestCase("JsonNode.Parse(json)", TestName = "A tree of JSON nodes")] + public async Task JsonWithGeneratedContractsIsLeftAlone(string expression) + { + var reported = await AnalyzeAsync(expression); + + Assert.That(reported, Is.Empty); + } + + private static async Task> AnalyzeAsync(string expression, string otherMembers = "") + { + var source = + $$""" + using System.Net.Http; + using System.Net.Http.Json; + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Text.Json.Serialization.Metadata; + + namespace Sample; + + public sealed class Meeting + { + public string Location { get; set; } = "Cologne"; + } + + public static class MeetingJson + { + private static readonly JsonSerializerOptions OPTIONS = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; + + {{otherMembers}} + + public static object? Use(Meeting meeting, string json, JsonElement element, HttpClient client, HttpContent content, JsonTypeInfo contract, IJsonTypeInfoResolver resolver) => {{expression}}; + } + """; + + return await CompilationHarness.AnalyzeAsync(source, new JsonSourceGenerationAnalyzer()); + } +} \ No newline at end of file diff --git a/app/Tests/Components/JsonTreeViewTests.cs b/app/Tests/Components/JsonTreeViewTests.cs new file mode 100644 index 000000000..e73ac95c1 --- /dev/null +++ b/app/Tests/Components/JsonTreeViewTests.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Nodes; + +using AIStudio.Components; + +namespace AIStudio.Tests.Components; + +/// +/// Checks how the tree of a tool result in the chat writes its keys and values. +/// +/// +/// The tree writes each key and each value as JSON. Escaping for a web page, as the default encoder of +/// System.Text.Json does, would show every umlaut and every quotation mark as a code of six characters, +/// although Blazor encodes the text for the page anyway. A value parsed from JSON goes through the same +/// writer as one created in code, so both get checked. +/// +[TestFixture] +public sealed class JsonTreeViewTests +{ + [TestCase("Größe der Übersicht")] + [TestCase("if (a < b && c > d) return 'x' + y;")] + [TestCase("日本語のテキスト")] + public void ValuesAppearAsWritten(string text) + { + var parsed = JsonNode.Parse(new JsonObject { ["text_content"] = text }.ToJsonString())!["text_content"]; + using (Assert.EnterMultipleScope()) + { + Assert.That(JsonTreeView.FormatValue(JsonValue.Create(text)), Is.EqualTo($"\"{text}\""), "A value created in code."); + Assert.That(JsonTreeView.FormatValue(parsed), Is.EqualTo($"\"{text}\""), "A value parsed from JSON."); + } + } + + [TestCase("Größe")] + [TestCase("a < b && c > d")] + public void KeysAppearAsWritten(string key) => Assert.That(JsonTreeView.FormatKey(key), Is.EqualTo($"\"{key}\"")); + + [Test] + public void WhatJsonHasToEscapeStaysEscaped() + { + const string TEXT = "A \"quoted\" line\nand a backslash \\ at its end."; + using (Assert.EnterMultipleScope()) + { + Assert.That(JsonNode.Parse(JsonTreeView.FormatValue(JsonValue.Create(TEXT)))?.GetValue(), Is.EqualTo(TEXT), "A value."); + Assert.That(JsonNode.Parse(JsonTreeView.FormatKey(TEXT))?.GetValue(), Is.EqualTo(TEXT), "A key."); + } + } +} \ No newline at end of file diff --git a/app/Tests/Provider/ModelListMetadataTests.cs b/app/Tests/Provider/ModelListMetadataTests.cs index b87f2e372..96ba3d273 100644 --- a/app/Tests/Provider/ModelListMetadataTests.cs +++ b/app/Tests/Provider/ModelListMetadataTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; +using AIStudio.Provider; using AIStudio.Provider.Groq; using AIStudio.Provider.OpenRouter; using AIStudio.Provider.Requesty; @@ -19,17 +20,12 @@ namespace AIStudio.Tests.Provider; /// below are shortened answers of the real routes, so that a rename is caught here rather than by /// somebody wondering why their window never shows up. /// -/// The options mirror what the providers deserialize with: names in snake case, which is what makes -/// the renaming attributes necessary in the first place. +/// The answers are read with the options of the providers themselves: names in snake case, which is +/// what makes the renaming attributes necessary in the first place. /// [TestFixture] public sealed class ModelListMetadataTests { - private static readonly JsonSerializerOptions AS_THE_PROVIDERS_READ_IT = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - }; - [Test] // ReSharper disable once InconsistentNaming public void VLLMStatesTheWindowItWasStartedWith() @@ -41,7 +37,7 @@ public sealed class ModelListMetadataTests { "id": "Qwen/Qwen3-32B", "object": "model", "owned_by": "vllm", "max_model_len": 32768 } ] } - """, AS_THE_PROVIDERS_READ_IT); + """, ProviderJsonOptions.OPTIONS); Assert.That(response.Data, Is.Not.Null); using (Assert.EnterMultipleScope()) @@ -63,7 +59,7 @@ public sealed class ModelListMetadataTests "object": "list", "data": [ { "id": "gemma3:1b", "object": "model" } ] } - """, AS_THE_PROVIDERS_READ_IT); + """, ProviderJsonOptions.OPTIONS); Assert.That(response.Data, Is.Not.Null); Assert.That(response.Data![0].ContextWindowTokens, Is.Null); @@ -84,7 +80,7 @@ public sealed class ModelListMetadataTests } ] } - """, AS_THE_PROVIDERS_READ_IT); + """, ProviderJsonOptions.OPTIONS); using (Assert.EnterMultipleScope()) { @@ -101,7 +97,7 @@ public sealed class ModelListMetadataTests "object": "list", "data": [ { "id": "llama-3.3-70b-versatile", "object": "model", "context_window": 131072 } ] } - """, AS_THE_PROVIDERS_READ_IT); + """, ProviderJsonOptions.OPTIONS); Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(131_072)); } @@ -114,7 +110,7 @@ public sealed class ModelListMetadataTests "object": "list", "data": [ { "id": "openai/gpt-4o-mini", "object": "model", "api": "chat", "context_window": 128000, "max_output_tokens": 16384 } ] } - """, AS_THE_PROVIDERS_READ_IT); + """, ProviderJsonOptions.OPTIONS); Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(128_000)); } @@ -127,7 +123,7 @@ public sealed class ModelListMetadataTests "object": "list", "data": [ { "id": "mistral-large-latest", "object": "model", "created": 1700000000, "owned_by": "mistralai", "max_context_length": 131072 } ] } - """, AS_THE_PROVIDERS_READ_IT); + """, ProviderJsonOptions.OPTIONS); Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(131_072)); } @@ -147,7 +143,7 @@ public sealed class ModelListMetadataTests } ] } - """, AS_THE_PROVIDERS_READ_IT); + """, ProviderJsonOptions.OPTIONS); Assert.That(response.Data[0].Providers, Is.Not.Null); Assert.That(response.Data[0].Providers![0].ContextWindowTokens, Is.EqualTo(64_000)); diff --git a/app/Tests/Provider/SelfHostedModelListTests.cs b/app/Tests/Provider/SelfHostedModelListTests.cs index 466f48e3b..ab29225e7 100644 --- a/app/Tests/Provider/SelfHostedModelListTests.cs +++ b/app/Tests/Provider/SelfHostedModelListTests.cs @@ -25,11 +25,6 @@ namespace AIStudio.Tests.Provider; [TestFixture] public sealed class SelfHostedModelListTests { - private static readonly JsonSerializerOptions AS_THE_PROVIDERS_READ_IT = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - }; - /// /// What "GET /v1/models" answers on a local Ollama, shortened to the fields it sends. /// @@ -51,7 +46,7 @@ public sealed class SelfHostedModelListTests private static IReadOnlyList TheModelsTheEngineListed() { - var response = JsonSerializer.Deserialize(WHAT_A_LOCAL_OLLAMA_ANSWERS, AS_THE_PROVIDERS_READ_IT); + var response = JsonSerializer.Deserialize(WHAT_A_LOCAL_OLLAMA_ANSWERS, ProviderJsonOptions.OPTIONS); Assert.That(response.Data, Is.Not.Null, "The answer has to be readable before anything can be sorted out of it."); return response.Data! diff --git a/app/Tests/Serialization/JsonOptionsResolverTests.cs b/app/Tests/Serialization/JsonOptionsResolverTests.cs new file mode 100644 index 000000000..5403f894d --- /dev/null +++ b/app/Tests/Serialization/JsonOptionsResolverTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AIStudio.TrimSmokeTest; + +namespace AIStudio.Tests.Serialization; + +/// +/// Checks that all options of the app get their contracts from source generation, never from reflection. +/// +/// +/// MWAIS0015 refuses options without a TypeInfoResolver while compiling, but cannot tell what the resolver does. +/// Only contexts throw for a type they do not know. Any other resolver, e.g., a DefaultJsonTypeInfoResolver which +/// a helper adds to the chain, builds the missing contract by reflection instead, and in a published build, the +/// code it needs for that may be gone. +/// +[TestFixture] +public sealed class JsonOptionsResolverTests +{ + private static IEnumerable AllOptions() + { + TestHost.PrepareStaticState(); + return AppJsonOptions.FindAll().Select(options => new TestCaseData(options.Value).SetArgDisplayNames(options.Key)); + } + + [TestCaseSource(nameof(AllOptions))] + public void TheOptionsResolveWithContextsOnly(JsonSerializerOptions options) + { + using (Assert.EnterMultipleScope()) + { + Assert.That(options.TypeInfoResolverChain, Is.Not.Empty); + Assert.That(options.TypeInfoResolverChain, Is.All.InstanceOf()); + } + } + + [Test] + public void FieldsAndPropertiesOfTheAppAreFound() + { + TestHost.PrepareStaticState(); + var names = AppJsonOptions.FindAll().Keys.ToList(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(names, Does.Contain("SettingsManager.JSON_OPTIONS")); + Assert.That(names, Does.Contain("VisualBriefingJson.Canonical")); + Assert.That(names, Has.None.Contains("k__BackingField")); + } + } +} \ No newline at end of file diff --git a/app/Tests/Tests.csproj b/app/Tests/Tests.csproj index 5277865b1..ee96038d5 100644 --- a/app/Tests/Tests.csproj +++ b/app/Tests/Tests.csproj @@ -45,6 +45,13 @@ + + + + + diff --git a/app/Tests/Tools/FileTypesTests.cs b/app/Tests/Tools/FileTypesTests.cs index 478e00b4e..50e66a4eb 100644 --- a/app/Tests/Tools/FileTypesTests.cs +++ b/app/Tests/Tools/FileTypesTests.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; using AIStudio.Tools.Validation; namespace AIStudio.Tests.Tools; @@ -122,7 +123,7 @@ public class FileTypesTests public void DialogFilterMatchesTheRuntimeContract() { // The struct FileTypeFilter in runtime/src/file_actions.rs knows exactly these two fields: - var json = JsonSerializer.Serialize(FileTypes.ToFileDialogFilter(FileTypes.JSON), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + var json = JsonSerializer.Serialize(FileTypes.ToFileDialogFilter(FileTypes.JSON), RustService.JSON_OPTIONS); Assert.That(json, Is.EqualTo("""{"filter_name":"JSON","filter_extensions":["json"]}""")); } diff --git a/app/Tests/Tools/ShareFileResponseTests.cs b/app/Tests/Tools/ShareFileResponseTests.cs index f90f15201..fddde6370 100644 --- a/app/Tests/Tools/ShareFileResponseTests.cs +++ b/app/Tests/Tools/ShareFileResponseTests.cs @@ -11,27 +11,18 @@ namespace AIStudio.Tests.Tools; /// /// ShareFileResponse in runtime/src/share_sheet.rs names the outcome in snake case. A name which the /// app does not know would not fail anywhere: the enum converter falls back to the first member, and -/// a share sheet which did not respond would go unnoticed again. The options mirror what RustService -/// deserializes with. +/// a share sheet which did not respond would go unnoticed again. The answers are read with the options +/// of RustService itself. /// [TestFixture] public sealed class ShareFileResponseTests { - private static readonly JsonSerializerOptions AS_THE_RUST_SERVICE_READS_IT = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - Converters = - { - new RustEnumConverter(), - }, - }; - [TestCase("opened", ShareSheetOutcome.OPENED)] [TestCase("failed", ShareSheetOutcome.FAILED)] [TestCase("unresponsive", ShareSheetOutcome.UNRESPONSIVE)] public void TheAppReadsEveryOutcomeOfTheRuntime(string outcome, ShareSheetOutcome expectedOutcome) { - var response = JsonSerializer.Deserialize($$"""{"outcome":"{{outcome}}","issue":"The main window is not available."}""", AS_THE_RUST_SERVICE_READS_IT); + var response = JsonSerializer.Deserialize($$"""{"outcome":"{{outcome}}","issue":"The main window is not available."}""", RustService.JSON_OPTIONS); Assert.That(response, Is.Not.Null); using (Assert.EnterMultipleScope()) @@ -44,7 +35,7 @@ public sealed class ShareFileResponseTests [Test] public void AnUnknownOutcomeNeverCountsAsAnOpenedShareSheet() { - var response = JsonSerializer.Deserialize("""{"outcome":"vanished","issue":""}""", AS_THE_RUST_SERVICE_READS_IT); + var response = JsonSerializer.Deserialize("""{"outcome":"vanished","issue":""}""", RustService.JSON_OPTIONS); Assert.That(response?.Outcome, Is.EqualTo(ShareSheetOutcome.UNKNOWN)); } diff --git a/app/TrimSmokeTest/AppJsonOptions.cs b/app/TrimSmokeTest/AppJsonOptions.cs new file mode 100644 index 000000000..46c6b1bf3 --- /dev/null +++ b/app/TrimSmokeTest/AppJsonOptions.cs @@ -0,0 +1,60 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AIStudio.TrimSmokeTest; + +/// +/// Finds the options with which AI Studio reads and writes JSON. +/// +/// +/// 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. +/// +public static class AppJsonOptions +{ + private const BindingFlags STATIC_MEMBERS = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + + /// + /// Returns the options of every static field and property of AI Studio which holds some. + /// + /// + /// 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. + /// + /// The options by the name of their type and member, e.g., SettingsManager.JSON_OPTIONS. + public static IReadOnlyDictionary FindAll() + { + var found = new SortedDictionary(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; + } +} \ No newline at end of file diff --git a/app/TrimSmokeTest/JsonContractWalker.cs b/app/TrimSmokeTest/JsonContractWalker.cs index 73af51fb2..392087164 100644 --- a/app/TrimSmokeTest/JsonContractWalker.cs +++ b/app/TrimSmokeTest/JsonContractWalker.cs @@ -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) diff --git a/app/TrimSmokeTest/JsonRoots.cs b/app/TrimSmokeTest/JsonRoots.cs index 909f3d79b..578232cb3 100644 --- a/app/TrimSmokeTest/JsonRoots.cs +++ b/app/TrimSmokeTest/JsonRoots.cs @@ -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; /// -/// 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. /// /// -/// 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.

-/// -/// 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. ///
public static class JsonRoots { - public static readonly IReadOnlyList ALL = - [ - // Chats, in the workspaces, among the temporary chats, and in chat archives: - new(typeof(ChatThread), WorkspaceBehaviour.JSON_OPTIONS), + /// + /// Returns every type registered in a context of the options of AI Studio, together with these options. + /// + /// + /// Options which several members share are checked once. Like AppJsonOptions.FindAll, this needs the + /// static state of the app to be filled before. + /// + public static IReadOnlyList FindAll() => AppJsonOptions.FindAll().Values + .Distinct(ReferenceEqualityComparer.Instance) + .Cast() + .SelectMany(options => options.TypeInfoResolverChain + .OfType() + .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 RegisteredTypes(JsonSerializerContext context) => context.GetType() + .GetCustomAttributesData() + .Where(attribute => attribute.AttributeType == typeof(JsonSerializableAttribute)) + .Select(attribute => (Type)attribute.ConstructorArguments[0].Value!); } \ No newline at end of file diff --git a/app/TrimSmokeTest/Program.cs b/app/TrimSmokeTest/Program.cs index 3309fc329..78e8f78e7 100644 --- a/app/TrimSmokeTest/Program.cs +++ b/app/TrimSmokeTest/Program.cs @@ -15,14 +15,22 @@ public static class Program // AIStudio.Program.LOGGER_FACTORY = NullLoggerFactory.Instance; + var roots = JsonRoots.FindAll(); var failures = new List(); - 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; } diff --git a/app/TrimSmokeTest/TrimSmokeTest.csproj b/app/TrimSmokeTest/TrimSmokeTest.csproj index 02b77017b..5a39621af 100644 --- a/app/TrimSmokeTest/TrimSmokeTest.csproj +++ b/app/TrimSmokeTest/TrimSmokeTest.csproj @@ -21,6 +21,16 @@ + + + + + diff --git a/app/Trimming.props b/app/Trimming.props index a8cc4a7c7..9b806ac0c 100644 --- a/app/Trimming.props +++ b/app/Trimming.props @@ -10,15 +10,17 @@ true false false - true + true true