Compare commits

...
21 changed files with 461 additions and 97 deletions

No files matched your search

+1
View File
@@ -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
+9
View File
@@ -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;
@@ -1,4 +1,3 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Components;
@@ -37,7 +36,7 @@ public partial class JsonTreeView : ComponentBase
private static List<TreeItemData<JsonTreeNode>> CreateChildren(JsonNode? value) => value switch
{
JsonObject jsonObject => jsonObject
.Select(property => CreateTreeItem(JsonSerializer.Serialize(property.Key), property.Value))
.Select(property => CreateTreeItem(JsonValue.Create(property.Key).ToJsonString(), property.Value))
.ToList(),
JsonArray jsonArray => jsonArray
.Select((item, index) => CreateTreeItem($"[{index}]", item))
@@ -21,7 +21,11 @@ public sealed class ToolExecutionResult
/// with every umlaut as six characters. The relaxed encoder escapes only what JSON requires.
/// </remarks>
// 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; }
@@ -0,0 +1,15 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace AIStudio.Tools.ToolCallingSystem;
/// <summary>
/// The contracts of the options with which a tool result is written for the model, built at compile time.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
[JsonSerializable(typeof(JsonNode))]
internal sealed partial class ToolExecutionResultJsonContext : JsonSerializerContext;
@@ -18,3 +18,4 @@
MWAIS0012 | Usage | Error | DirectI18NGetTextAnalyzer
MWAIS0013 | Usage | Error | ModelPatternLiteralAnalyzer
MWAIS0014 | Usage | Error | MissingInterpolationAnalyzer
MWAIS0015 | Usage | Error | JsonSourceGenerationAnalyzer
@@ -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";
}
@@ -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;
/// <summary>
/// Reports JSON which System.Text.Json would read or write with contracts it builds by reflection.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
#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.";
/// <summary>
/// The types whose methods read or write JSON, and which fall back to reflection without options.
/// </summary>
private static readonly ImmutableHashSet<string> 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<DiagnosticDescriptor> 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);
}
/// <summary>
/// Reports reading or writing JSON without options, or with options which are null.
/// </summary>
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."));
}
/// <summary>
/// Reports the shared options of System.Text.Json, which build every contract by reflection.
/// </summary>
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."));
}
/// <summary>
/// Reports a resolver which builds contracts by reflection, and new options which name no resolver.
/// </summary>
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."));
}
/// <summary>
/// Whether a parameter takes a contract, as a JsonTypeInfo, or the context which holds the contracts.
/// </summary>
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;
}
@@ -0,0 +1,106 @@
using AIStudio.Tests.Models.Generation;
using Microsoft.CodeAnalysis;
using SourceCodeRules.UsageAnalyzers;
namespace AIStudio.Tests.Analyzers;
/// <summary>
/// Checks that every way into JSON contracts built by reflection is refused while compiling.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<Meeting>(json)", "JsonSerializer.Deserialize gets no options", TestName = "Reading without options")]
[TestCase("element.Deserialize<Meeting>()", "JsonSerializer.Deserialize gets no options", TestName = "Reading a JSON element without options")]
[TestCase("client.GetFromJsonAsync<Meeting>(\"/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<Meeting>()", "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>(\"/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<IReadOnlyList<Diagnostic>> 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<Meeting> contract, IJsonTypeInfoResolver resolver) => {{expression}};
}
""";
return await CompilationHarness.AnalyzeAsync(source, new JsonSourceGenerationAnalyzer());
}
}
+10 -14
View File
@@ -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.
/// </remarks>
[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));
@@ -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,
};
/// <summary>
/// What "GET /v1/models" answers on a local Ollama, shortened to the fields it sends.
/// </summary>
@@ -51,7 +46,7 @@ public sealed class SelfHostedModelListTests
private static IReadOnlyList<Model> TheModelsTheEngineListed()
{
var response = JsonSerializer.Deserialize<SelfHostedModelsResponse>(WHAT_A_LOCAL_OLLAMA_ANSWERS, AS_THE_PROVIDERS_READ_IT);
var response = JsonSerializer.Deserialize<SelfHostedModelsResponse>(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!
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AIStudio.TrimSmokeTest;
namespace AIStudio.Tests.Serialization;
/// <summary>
/// Checks that all options of the app get their contracts from source generation, never from reflection.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[TestFixture]
public sealed class JsonOptionsResolverTests
{
private static IEnumerable<TestCaseData> 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<JsonSerializerContext>());
}
}
[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"));
}
}
}
+7
View File
@@ -45,6 +45,13 @@
<ProjectReference Include="..\SourceGeneratedMappings\SourceGeneratedMappings.csproj" />
</ItemGroup>
<!--
Compiled here as well, so that the tests check the same options of the app which the trim smoke test finds.
-->
<ItemGroup>
<Compile Include="..\TrimSmokeTest\AppJsonOptions.cs" Link="Serialization\AppJsonOptions.cs" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
+2 -1
View File
@@ -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"]}"""));
}
+4 -13
View File
@@ -11,27 +11,18 @@ namespace AIStudio.Tests.Tools;
/// <remarks>
/// 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.
/// </remarks>
[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<ShareFileResponse>($$"""{"outcome":"{{outcome}}","issue":"The main window is not available."}""", AS_THE_RUST_SERVICE_READS_IT);
var response = JsonSerializer.Deserialize<ShareFileResponse>($$"""{"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<ShareFileResponse>("""{"outcome":"vanished","issue":""}""", AS_THE_RUST_SERVICE_READS_IT);
var response = JsonSerializer.Deserialize<ShareFileResponse>("""{"outcome":"vanished","issue":""}""", RustService.JSON_OPTIONS);
Assert.That(response?.Outcome, Is.EqualTo(ShareSheetOutcome.UNKNOWN));
}
+54
View File
@@ -0,0 +1,54 @@
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);
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!);
}
+3 -2
View File
@@ -15,14 +15,15 @@ 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)
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>
+6 -4
View File
@@ -10,15 +10,17 @@
<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols> <!-- Remove debug symbols -->
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization> <!-- Remove unsafe binary formatter serialization -->
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding> <!-- Remove unsafe UTF7 encoding -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <!-- Enable reflection for JSON serialization -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <!-- Blazor Server needs reflection for JSON of its own, while the options of AI Studio use generated contracts only. Without this line, PublishTrimmed switches reflection off, even for dotnet run -->
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings> <!-- Suppress trim analysis warnings -->
</PropertyGroup>
<!--
System.Text.Json builds an immutable collection, e.g., the ImmutableHashSet of a chat, through a factory method
like ImmutableHashSet.CreateRange, which it finds by reflection at runtime. Nothing calls that method directly,
so the trimmer removes it, and every type which holds such a collection fails to load and to save. Only a
published build shows this: debug builds and the tests are not trimmed. Hence, we keep the whole assembly.
like ImmutableHashSet.CreateRange. The contracts which AI Studio generates for its own options call that method
directly, so the trimmer keeps it, and the trim smoke test passes without the line below. A contract which
System.Text.Json builds by reflection, as it still does for the JSON of Blazor, finds the method at runtime
only, and nothing would keep it then. The whole assembly costs about 70 KB more than its trimmed part, so we
keep it, in case such a contract ever meets an immutable collection.
-->
<ItemGroup>
<TrimmerRootAssembly Include="System.Collections.Immutable" />