Added the visual briefing assistant (#893)
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-02 21:41:12 +02:00
1 parent df4663fff4
commit 58cc811a58
214 files changed
+18943 -353

No files matched your search

@@ -12,4 +12,6 @@
MWAIS0006 | Style | Error | SwitchExpressionMethodAnalyzer
MWAIS0007 | Usage | Error | EmptyStringAnalyzer
MWAIS0008 | Naming | Error | LocalConstantsAnalyzer
MWAIS0009 | Usage | Error | StaticServiceProviderCacheAnalyzer
MWAIS0009 | Usage | Error | StaticServiceProviderCacheAnalyzer
MWAIS0010 | Usage | Error | CanonicalJsonConfigurationAnalyzer
MWAIS0011 | Usage | Error | CanonicalJsonShapeAnalyzer
@@ -1,7 +1,8 @@
### New Rules
Rule ID | Category | Severity | Notes
---------|----------|----------|-------
Rule ID | Category | Severity | Notes
-----------|----------|----------|--------------------------------------
### Changed Rules
@@ -11,4 +11,6 @@ public static class Identifier
public const string EMPTY_STRING_ANALYZER = $"{Tools.ID_PREFIX}0007";
public const string LOCAL_CONSTANTS_ANALYZER = $"{Tools.ID_PREFIX}0008";
public const string STATIC_SERVICE_PROVIDER_CACHE_ANALYZER = $"{Tools.ID_PREFIX}0009";
public const string CANONICAL_JSON_CONFIGURATION_ANALYZER = $"{Tools.ID_PREFIX}0010";
public const string CANONICAL_JSON_SHAPE_ANALYZER = $"{Tools.ID_PREFIX}0011";
}
@@ -0,0 +1,140 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace SourceCodeRules.UsageAnalyzers;
#pragma warning disable RS1038
[DiagnosticAnalyzer(LanguageNames.CSharp)]
#pragma warning restore RS1038
public sealed class CanonicalJsonConfigurationAnalyzer : DiagnosticAnalyzer
{
private const string DIAGNOSTIC_ID = Identifier.CANONICAL_JSON_CONFIGURATION_ANALYZER;
private const string ATTRIBUTE_NAME = "CanonicalJsonConfigurationAttribute";
private const string CONVERTERS = "Converters";
private const string TITLE = "Canonical JSON options must stay frozen and self-contained";
private const string MESSAGE_FORMAT = "{0} The byte output of these options is hashed into stored data, so any change to them makes previously stored data fail its integrity check";
private const string DESCRIPTION = "Canonical JSON options are frozen because their exact byte output is hashed into stored data. They must be initialized inline at their own declaration, must not declare converters, and must not be reconfigured afterwards, so that a change meant for other serializer options cannot reach them through a shared factory.";
private const string CATEGORY = "Usage";
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.RegisterSyntaxNodeAction(AnalyzeProperty, SyntaxKind.PropertyDeclaration);
context.RegisterSyntaxNodeAction(AnalyzeField, SyntaxKind.FieldDeclaration);
context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
context.RegisterSyntaxNodeAction(AnalyzeAssignment, SyntaxKind.SimpleAssignmentExpression);
}
private static void AnalyzeProperty(SyntaxNodeAnalysisContext context)
{
var declaration = (PropertyDeclarationSyntax)context.Node;
if (context.SemanticModel.GetDeclaredSymbol(declaration) is not { } symbol || !IsMarked(symbol))
return;
AnalyzeInitializer(context, declaration.Initializer?.Value, declaration.Identifier.GetLocation());
}
private static void AnalyzeField(SyntaxNodeAnalysisContext context)
{
var declaration = (FieldDeclarationSyntax)context.Node;
foreach (var variable in declaration.Declaration.Variables)
{
if (context.SemanticModel.GetDeclaredSymbol(variable) is not { } symbol || !IsMarked(symbol))
continue;
AnalyzeInitializer(context, variable.Initializer?.Value, variable.Identifier.GetLocation());
}
}
/// <summary>
/// Requires the complete configuration to be visible at the declaration itself.
/// </summary>
private static void AnalyzeInitializer(SyntaxNodeAnalysisContext context, ExpressionSyntax? initializer, Location location)
{
if (initializer is null)
{
context.ReportDiagnostic(Diagnostic.Create(RULE, location, "Canonical JSON options must be initialized where they are declared."));
return;
}
if (initializer is not ObjectCreationExpressionSyntax and not ImplicitObjectCreationExpressionSyntax)
{
context.ReportDiagnostic(Diagnostic.Create(RULE, initializer.GetLocation(), "Canonical JSON options must be created inline instead of by a helper, so that every setting is visible here and cannot be changed through a shared factory."));
return;
}
var settings = initializer switch
{
ObjectCreationExpressionSyntax objectCreation => objectCreation.Initializer,
ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.Initializer,
_ => null,
};
if (settings is null)
return;
foreach (var expression in settings.Expressions)
{
var name = expression switch
{
AssignmentExpressionSyntax { Left: IdentifierNameSyntax identifier } => identifier.Identifier.Text,
_ => null,
};
if (name == CONVERTERS)
context.ReportDiagnostic(Diagnostic.Create(RULE, expression.GetLocation(), "Canonical JSON options must not declare converters."));
}
}
/// <summary>
/// Reports reaching for the converter collection of already declared canonical options.
/// </summary>
private static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
{
var memberAccess = (MemberAccessExpressionSyntax)context.Node;
if (memberAccess.Name.Identifier.Text != CONVERTERS)
return;
if (!IsMarked(context.SemanticModel.GetSymbolInfo(memberAccess.Expression).Symbol))
return;
context.ReportDiagnostic(Diagnostic.Create(RULE, memberAccess.GetLocation(), "Canonical JSON options must not gain converters after they were declared."));
}
/// <summary>
/// Reports assigning any setting of already declared canonical options.
/// </summary>
private static void AnalyzeAssignment(SyntaxNodeAnalysisContext context)
{
var assignment = (AssignmentExpressionSyntax)context.Node;
if (assignment.Left is not MemberAccessExpressionSyntax memberAccess)
return;
if (!IsMarked(context.SemanticModel.GetSymbolInfo(memberAccess.Expression).Symbol))
return;
context.ReportDiagnostic(Diagnostic.Create(RULE, assignment.GetLocation(), "Canonical JSON options must not be reconfigured after they were declared."));
}
private static bool IsMarked(ISymbol? symbol) =>
symbol is IPropertySymbol or IFieldSymbol &&
symbol.GetAttributes().Any(attribute => attribute.AttributeClass?.Name == ATTRIBUTE_NAME);
}
@@ -0,0 +1,149 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
namespace SourceCodeRules.UsageAnalyzers;
#pragma warning disable RS1038
[DiagnosticAnalyzer(LanguageNames.CSharp)]
#pragma warning restore RS1038
public sealed class CanonicalJsonShapeAnalyzer : DiagnosticAnalyzer
{
private const string DIAGNOSTIC_ID = Identifier.CANONICAL_JSON_SHAPE_ANALYZER;
private const string ATTRIBUTE_NAME = "CanonicalJsonShapeAttribute";
private const string PROPERTY_NAME_ATTRIBUTE = "JsonPropertyNameAttribute";
private const string IGNORE_ATTRIBUTE = "JsonIgnoreAttribute";
private const string CONDITION_ARGUMENT = "Condition";
private const string DEFAULT_CONDITION = "Always";
private const string TITLE = "Canonical JSON shape must match its declared signature";
private const string MESSAGE_FORMAT = "The JSON shape of '{0}' no longer matches its declared signature. Data that was hashed with the previous shape stops being readable, so update the attribute to \"{1}\" only once that is acceptable.";
private const string DESCRIPTION = "The serialized form of this type is hashed into stored data. Adding, removing, renaming, or retyping a property changes those bytes and makes previously stored data fail its integrity check, which surfaces as unreadable data rather than as an error. The declared signature exists so that such a change cannot pass unnoticed.";
private const string CATEGORY = "Usage";
private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION);
/// <summary>
/// Renders property types the way they are written in the source, including nullable annotations.
/// </summary>
private static readonly SymbolDisplayFormat TYPE_FORMAT = SymbolDisplayFormat.MinimallyQualifiedFormat.WithMiscellaneousOptions(
SymbolDisplayMiscellaneousOptions.UseSpecialTypes | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [RULE];
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(AnalyzeType, SymbolKind.NamedType);
}
private static void AnalyzeType(SymbolAnalysisContext context)
{
var type = (INamedTypeSymbol)context.Symbol;
var declaration = type.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass?.Name == ATTRIBUTE_NAME);
if (declaration is null)
return;
var declared = declaration.ConstructorArguments.Length > 0 ? declaration.ConstructorArguments[0].Value as string : null;
var actual = ComputeSignature(type);
if (declared == actual)
return;
var location = declaration.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation() ?? type.Locations.FirstOrDefault();
if (location is not null)
context.ReportDiagnostic(Diagnostic.Create(RULE, location, type.Name, actual));
}
/// <summary>
/// Derives the shape signature from everything that changes the serialized bytes.
/// </summary>
/// <remarks>
/// Entries are ordered by their JSON name rather than by declaration order, because the hashed JSON
/// is canonicalized with ordinally sorted properties. Moving a property within its type therefore
/// does not change any stored hash, and must not fail the build either.
/// </remarks>
/// <param name="type">The type to inspect.</param>
/// <returns>The signature of the serialized shape.</returns>
private static string ComputeSignature(INamedTypeSymbol type)
{
List<string> entries = [];
foreach (var property in type.GetMembers().OfType<IPropertySymbol>())
{
if (property.IsStatic || property.IsIndexer || property.GetMethod is null || property.DeclaredAccessibility != Accessibility.Public)
continue;
entries.Add($"{JsonName(property)}|{property.Type.ToDisplayString(TYPE_FORMAT)}|{IgnoreMarker(property)}");
}
entries.Sort(System.StringComparer.Ordinal);
return Fnv1A(string.Join("\n", entries));
}
/// <summary>
/// Gets the JSON name a property is written with.
/// </summary>
private static string JsonName(IPropertySymbol property)
{
var attribute = property.GetAttributes().FirstOrDefault(candidate => candidate.AttributeClass?.Name == PROPERTY_NAME_ATTRIBUTE);
if (attribute is not null && attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is string name)
return name;
return property.Name;
}
/// <summary>
/// Gets the ignore behavior of a property, which decides whether it appears at all.
/// </summary>
private static string IgnoreMarker(IPropertySymbol property)
{
var attribute = property.GetAttributes().FirstOrDefault(candidate => candidate.AttributeClass?.Name == IGNORE_ATTRIBUTE);
if (attribute is null)
return string.Empty;
foreach (var argument in attribute.NamedArguments)
{
if (argument.Key != CONDITION_ARGUMENT)
continue;
var rendered = argument.Value.ToCSharpString();
var separator = rendered.LastIndexOf('.');
return separator < 0 ? rendered : rendered.Substring(separator + 1);
}
return DEFAULT_CONDITION;
}
/// <summary>
/// Computes a stable 32-bit FNV-1a hash, rendered as eight lowercase hexadecimal digits.
/// </summary>
/// <remarks>
/// The built-in string hash is randomized per process and would produce a different signature on
/// every build, so the signature is computed explicitly here.
/// </remarks>
/// <param name="value">The text to hash.</param>
/// <returns>The signature text.</returns>
private static string Fnv1A(string value)
{
var hash = 2166136261u;
foreach (var character in value)
{
hash ^= character;
hash *= 16777619u;
}
return hash.ToString("x8");
}
}