Added rule MWAIS0011 to pin the JSON shape of hashed types

This commit is contained in:
Thorsten Sommer 2026-08-02 19:02:56 +02:00
parent 22331912eb
commit f1bcfbc2c0
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
23 changed files with 226 additions and 30 deletions

View File

@ -6,23 +6,24 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Describes one visual asset without embedding its bytes. /// Describes one visual asset without embedding its bytes.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("d05cdc87")]
public sealed class VisualBriefingAssetPlanItem public sealed class VisualBriefingAssetPlanItem
{ {
/// <summary> /// <summary>
/// Gets or sets the stable visual asset identifier. /// Gets or sets the stable visual asset identifier.
/// </summary> /// </summary>
[JsonRequired] [JsonRequired]
public string AssetId { get; set; } = string.Empty; public string AssetId { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the model's visual description for presentation decisions. /// Gets or sets the model's visual description for presentation decisions.
/// </summary> /// </summary>
[JsonRequired] [JsonRequired]
public string Description { get; set; } = string.Empty; public string Description { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the target-language text alternative. /// Gets or sets the target-language text alternative.
/// </summary> /// </summary>
[JsonRequired] [JsonRequired]
public string AltText { get; set; } = string.Empty; public string AltText { get; init; } = string.Empty;
} }

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Defines one named numeric series in a chart specification. /// Defines one named numeric series in a chart specification.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("57679f28")]
public sealed class VisualBriefingChartSeries public sealed class VisualBriefingChartSeries
{ {
/// <summary>Gets or sets the series name.</summary> /// <summary>Gets or sets the series name.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Defines the bounded semantic input for one compiled chart. /// Defines the bounded semantic input for one compiled chart.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("68b2ff45")]
public sealed class VisualBriefingChartSpec public sealed class VisualBriefingChartSpec
{ {
/// <summary>Gets or sets the owning component identifier.</summary> /// <summary>Gets or sets the owning component identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Defines one value and visible label offered by an interaction control. /// Defines one value and visible label offered by an interaction control.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("08092336")]
public sealed class VisualBriefingControlOption public sealed class VisualBriefingControlOption
{ {
/// <summary>Gets or sets the stored option value.</summary> /// <summary>Gets or sets the stored option value.</summary>

View File

@ -7,6 +7,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Defines one bounded declarative interaction control. /// Defines one bounded declarative interaction control.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("42306121")]
public sealed class VisualBriefingControlSpec public sealed class VisualBriefingControlSpec
{ {
/// <summary>Gets or sets the globally unique control identifier.</summary> /// <summary>Gets or sets the globally unique control identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Describes one sourced factual statement extracted during evidence analysis. /// Describes one sourced factual statement extracted during evidence analysis.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("7857e7da")]
public sealed class VisualBriefingEvidenceFact public sealed class VisualBriefingEvidenceFact
{ {
/// <summary>Gets or sets the stable evidence identifier.</summary> /// <summary>Gets or sets the stable evidence identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Describes one sourced numeric metric extracted during evidence analysis. /// Describes one sourced numeric metric extracted during evidence analysis.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("08d12050")]
public sealed class VisualBriefingEvidenceMetric public sealed class VisualBriefingEvidenceMetric
{ {
/// <summary>Gets or sets the stable evidence identifier.</summary> /// <summary>Gets or sets the stable evidence identifier.</summary>

View File

@ -7,6 +7,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Describes one sourced table extracted during evidence analysis. /// Describes one sourced table extracted during evidence analysis.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("ad23c5b0")]
public sealed class VisualBriefingEvidenceTable public sealed class VisualBriefingEvidenceTable
{ {
/// <summary>Gets or sets the stable evidence identifier.</summary> /// <summary>Gets or sets the stable evidence identifier.</summary>

View File

@ -5,107 +5,108 @@ namespace AIStudio.Assistants.VisualBriefing;
/// <summary> /// <summary>
/// Defines <c>VisualBriefingExportManifest</c> for the visual briefing feature. /// Defines <c>VisualBriefingExportManifest</c> for the visual briefing feature.
/// </summary> /// </summary>
[CanonicalJsonShape("fc2235e8")]
public sealed class VisualBriefingExportManifest public sealed class VisualBriefingExportManifest
{ {
/// <summary> /// <summary>
/// Defines <c>ArtifactVersion</c> for the visual briefing feature. /// Defines <c>ArtifactVersion</c> for the visual briefing feature.
/// </summary> /// </summary>
public int ArtifactVersion { get; set; } = VisualBriefingVersions.ARTIFACT; public int ArtifactVersion { get; init; } = VisualBriefingVersions.ARTIFACT;
/// <summary> /// <summary>
/// Defines <c>SchemaVersion</c> for the visual briefing feature. /// Defines <c>SchemaVersion</c> for the visual briefing feature.
/// </summary> /// </summary>
public int SchemaVersion { get; set; } = VisualBriefingVersions.SCHEMA; public int SchemaVersion { get; init; } = VisualBriefingVersions.SCHEMA;
/// <summary> /// <summary>
/// Defines <c>RuntimeVersion</c> for the visual briefing feature. /// Defines <c>RuntimeVersion</c> for the visual briefing feature.
/// </summary> /// </summary>
public int RuntimeVersion { get; set; } = VisualBriefingVersions.RUNTIME; public int RuntimeVersion { get; init; } = VisualBriefingVersions.RUNTIME;
/// <summary> /// <summary>
/// Defines <c>BriefingId</c> for the visual briefing feature. /// Defines <c>BriefingId</c> for the visual briefing feature.
/// </summary> /// </summary>
public Guid BriefingId { get; set; } public Guid BriefingId { get; init; }
/// <summary> /// <summary>
/// Defines <c>RevisionId</c> for the visual briefing feature. /// Defines <c>RevisionId</c> for the visual briefing feature.
/// </summary> /// </summary>
public Guid RevisionId { get; set; } public Guid RevisionId { get; init; }
/// <summary> /// <summary>
/// Defines <c>ParentRevisionId</c> for the visual briefing feature. /// Defines <c>ParentRevisionId</c> for the visual briefing feature.
/// </summary> /// </summary>
public Guid? ParentRevisionId { get; set; } public Guid? ParentRevisionId { get; init; }
/// <summary> /// <summary>
/// Defines <c>Name</c> for the visual briefing feature. /// Defines <c>Name</c> for the visual briefing feature.
/// </summary> /// </summary>
public string Name { get; set; } = string.Empty; public string Name { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Defines <c>Author</c> for the visual briefing feature. /// Defines <c>Author</c> for the visual briefing feature.
/// </summary> /// </summary>
public string Author { get; set; } = string.Empty; public string Author { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Defines <c>CreatedAtUtc</c> for the visual briefing feature. /// Defines <c>CreatedAtUtc</c> for the visual briefing feature.
/// </summary> /// </summary>
public DateTimeOffset CreatedAtUtc { get; set; } public DateTimeOffset CreatedAtUtc { get; init; }
/// <summary> /// <summary>
/// Defines <c>TargetLanguage</c> for the visual briefing feature. /// Defines <c>TargetLanguage</c> for the visual briefing feature.
/// </summary> /// </summary>
public CommonLanguages TargetLanguage { get; set; } public CommonLanguages TargetLanguage { get; init; }
/// <summary> /// <summary>
/// Defines <c>CustomTargetLanguage</c> for the visual briefing feature. /// Defines <c>CustomTargetLanguage</c> for the visual briefing feature.
/// </summary> /// </summary>
public string CustomTargetLanguage { get; set; } = string.Empty; public string CustomTargetLanguage { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Defines <c>AudienceProfile</c> for the visual briefing feature. /// Defines <c>AudienceProfile</c> for the visual briefing feature.
/// </summary> /// </summary>
public AudienceProfile AudienceProfile { get; set; } public AudienceProfile AudienceProfile { get; init; }
/// <summary> /// <summary>
/// Defines <c>AudienceAgeGroup</c> for the visual briefing feature. /// Defines <c>AudienceAgeGroup</c> for the visual briefing feature.
/// </summary> /// </summary>
public AudienceAgeGroup AudienceAgeGroup { get; set; } public AudienceAgeGroup AudienceAgeGroup { get; init; }
/// <summary> /// <summary>
/// Defines <c>AudienceOrganizationalLevel</c> for the visual briefing feature. /// Defines <c>AudienceOrganizationalLevel</c> for the visual briefing feature.
/// </summary> /// </summary>
public AudienceOrganizationalLevel AudienceOrganizationalLevel { get; set; } public AudienceOrganizationalLevel AudienceOrganizationalLevel { get; init; }
/// <summary> /// <summary>
/// Defines <c>AudienceExpertise</c> for the visual briefing feature. /// Defines <c>AudienceExpertise</c> for the visual briefing feature.
/// </summary> /// </summary>
public AudienceExpertise AudienceExpertise { get; set; } public AudienceExpertise AudienceExpertise { get; init; }
/// <summary> /// <summary>
/// Defines <c>ShowSourceReferences</c> for the visual briefing feature. /// Defines <c>ShowSourceReferences</c> for the visual briefing feature.
/// </summary> /// </summary>
public bool ShowSourceReferences { get; set; } public bool ShowSourceReferences { get; init; }
/// <summary> /// <summary>
/// Defines <c>ProtectionLevel</c> for the visual briefing feature. /// Defines <c>ProtectionLevel</c> for the visual briefing feature.
/// </summary> /// </summary>
public VisualBriefingProtectionLevel ProtectionLevel { get; set; } public VisualBriefingProtectionLevel ProtectionLevel { get; init; }
/// <summary> /// <summary>
/// Defines <c>CustomProtectionLevel</c> for the visual briefing feature. /// Defines <c>CustomProtectionLevel</c> for the visual briefing feature.
/// </summary> /// </summary>
public string CustomProtectionLevel { get; set; } = string.Empty; public string CustomProtectionLevel { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Defines <c>AIStudioVersion</c> for the visual briefing feature. /// Defines <c>AIStudioVersion</c> for the visual briefing feature.
/// </summary> /// </summary>
public string AIStudioVersion { get; set; } = string.Empty; public string AIStudioVersion { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Defines <c>RuntimeAIStudioVersion</c> for the visual briefing feature. /// Defines <c>RuntimeAIStudioVersion</c> for the visual briefing feature.
/// </summary> /// </summary>
public string RuntimeAIStudioVersion { get; set; } = string.Empty; public string RuntimeAIStudioVersion { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the SHA-256 hash of the complete standalone HTML document. /// Gets or sets the SHA-256 hash of the complete standalone HTML document.

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// <summary> /// <summary>
/// Defines <c>VisualBriefingFormulaNode</c> for the visual briefing feature. /// Defines <c>VisualBriefingFormulaNode</c> for the visual briefing feature.
/// </summary> /// </summary>
[CanonicalJsonShape("aa29e015")]
public sealed class VisualBriefingFormulaNode public sealed class VisualBriefingFormulaNode
{ {
/// <summary> /// <summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Connects one deterministic formula tree to a component result slot. /// Connects one deterministic formula tree to a component result slot.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("b644b191")]
public sealed class VisualBriefingFormulaSpec public sealed class VisualBriefingFormulaSpec
{ {
/// <summary>Gets or sets the owning component identifier.</summary> /// <summary>Gets or sets the owning component identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Defines one node in the validated bounded presentation layout tree. /// Defines one node in the validated bounded presentation layout tree.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("14064835")]
public sealed class VisualBriefingLayoutNode public sealed class VisualBriefingLayoutNode
{ {
/// <summary>Gets or sets the globally unique layout node identifier.</summary> /// <summary>Gets or sets the globally unique layout node identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Plans one semantic component and its evidence and content dependencies. /// Plans one semantic component and its evidence and content dependencies.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("bdafbeaf")]
public sealed class VisualBriefingPlanComponent public sealed class VisualBriefingPlanComponent
{ {
/// <summary>Gets or sets the globally unique component identifier.</summary> /// <summary>Gets or sets the globally unique component identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Plans one narrative section and its ordered components. /// Plans one narrative section and its ordered components.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("91d1394d")]
public sealed class VisualBriefingPlanSection public sealed class VisualBriefingPlanSection
{ {
/// <summary>Gets or sets the globally unique section identifier.</summary> /// <summary>Gets or sets the globally unique section identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Plans one semantic content slot owned by a component. /// Plans one semantic content slot owned by a component.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("04cc2e77")]
public sealed class VisualBriefingPlanSlot public sealed class VisualBriefingPlanSlot
{ {
/// <summary>Gets or sets the globally unique slot identifier.</summary> /// <summary>Gets or sets the globally unique slot identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Defines bounded responsive column counts for one grid layout node. /// Defines bounded responsive column counts for one grid layout node.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("92c96e68")]
public sealed class VisualBriefingResponsiveColumns public sealed class VisualBriefingResponsiveColumns
{ {
/// <summary>Gets or sets the mobile column count.</summary> /// <summary>Gets or sets the mobile column count.</summary>

View File

@ -7,6 +7,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Assigns a validated JSON value to one planned semantic slot. /// Assigns a validated JSON value to one planned semantic slot.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("6cfa3f02")]
public sealed class VisualBriefingSlotValue public sealed class VisualBriefingSlotValue
{ {
/// <summary>Gets or sets the planned slot identifier.</summary> /// <summary>Gets or sets the planned slot identifier.</summary>

View File

@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing;
/// Records how one source contributed to canonical content. /// Records how one source contributed to canonical content.
/// </summary> /// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
[CanonicalJsonShape("b1535c0e")]
public sealed class VisualBriefingSourceCoverage public sealed class VisualBriefingSourceCoverage
{ {
/// <summary> /// <summary>

View File

@ -0,0 +1,27 @@
namespace AIStudio.Tools;
/// <summary>
/// Pins the JSON shape of a type whose serialized form is hashed into stored data.
/// </summary>
/// <remarks>
/// A Roslyn analyzer only ever sees the current code, so it cannot notice that a property was added
/// yesterday. Declaring the expected shape here gives it something to compare against: rule MWAIS0011
/// derives a signature from the properties, their JSON names, their types, and their ignore conditions,
/// and fails the build when it no longer matches. The point is not the value itself but the moment it
/// forces — updating it is the step where somebody has to decide whether existing stored data may stop
/// being readable, and the changed value makes that decision visible in the diff.
/// Only types whose JSON is hashed directly carry this attribute. The artifact envelopes around them do
/// not, because the parts of them that reach a hash are named one by one in
/// <c>VisualBriefingPayloadHash</c>, where changing a type breaks the build on its own.
/// Attributes that affect reading rather than writing, such as <c>JsonRequired</c>, are not part of the
/// signature: they cannot change the bytes that were hashed.
/// </remarks>
/// <param name="signature">The expected shape signature, reported by MWAIS0011 whenever it changes.</param>
[AttributeUsage(AttributeTargets.Class)]
public sealed class CanonicalJsonShapeAttribute(string signature) : Attribute
{
/// <summary>
/// Gets the expected shape signature.
/// </summary>
public string Signature { get; } = signature;
}

View File

@ -13,4 +13,5 @@
MWAIS0007 | Usage | Error | EmptyStringAnalyzer MWAIS0007 | Usage | Error | EmptyStringAnalyzer
MWAIS0008 | Naming | Error | LocalConstantsAnalyzer MWAIS0008 | Naming | Error | LocalConstantsAnalyzer
MWAIS0009 | Usage | Error | StaticServiceProviderCacheAnalyzer MWAIS0009 | Usage | Error | StaticServiceProviderCacheAnalyzer
MWAIS0010 | Usage | Error | CanonicalJsonConfigurationAnalyzer MWAIS0010 | Usage | Error | CanonicalJsonConfigurationAnalyzer
MWAIS0011 | Usage | Error | CanonicalJsonShapeAnalyzer

View File

@ -12,4 +12,5 @@ public static class Identifier
public const string LOCAL_CONSTANTS_ANALYZER = $"{Tools.ID_PREFIX}0008"; 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 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_CONFIGURATION_ANALYZER = $"{Tools.ID_PREFIX}0010";
public const string CANONICAL_JSON_SHAPE_ANALYZER = $"{Tools.ID_PREFIX}0011";
} }

View File

@ -35,13 +35,13 @@ public sealed class CanonicalJsonConfigurationAnalyzer : DiagnosticAnalyzer
{ {
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution(); context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(this.AnalyzeProperty, SyntaxKind.PropertyDeclaration); context.RegisterSyntaxNodeAction(AnalyzeProperty, SyntaxKind.PropertyDeclaration);
context.RegisterSyntaxNodeAction(this.AnalyzeField, SyntaxKind.FieldDeclaration); context.RegisterSyntaxNodeAction(AnalyzeField, SyntaxKind.FieldDeclaration);
context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression); context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
context.RegisterSyntaxNodeAction(this.AnalyzeAssignment, SyntaxKind.SimpleAssignmentExpression); context.RegisterSyntaxNodeAction(AnalyzeAssignment, SyntaxKind.SimpleAssignmentExpression);
} }
private void AnalyzeProperty(SyntaxNodeAnalysisContext context) private static void AnalyzeProperty(SyntaxNodeAnalysisContext context)
{ {
var declaration = (PropertyDeclarationSyntax)context.Node; var declaration = (PropertyDeclarationSyntax)context.Node;
if (context.SemanticModel.GetDeclaredSymbol(declaration) is not { } symbol || !IsMarked(symbol)) if (context.SemanticModel.GetDeclaredSymbol(declaration) is not { } symbol || !IsMarked(symbol))
@ -50,7 +50,7 @@ public sealed class CanonicalJsonConfigurationAnalyzer : DiagnosticAnalyzer
AnalyzeInitializer(context, declaration.Initializer?.Value, declaration.Identifier.GetLocation()); AnalyzeInitializer(context, declaration.Initializer?.Value, declaration.Identifier.GetLocation());
} }
private void AnalyzeField(SyntaxNodeAnalysisContext context) private static void AnalyzeField(SyntaxNodeAnalysisContext context)
{ {
var declaration = (FieldDeclarationSyntax)context.Node; var declaration = (FieldDeclarationSyntax)context.Node;
foreach (var variable in declaration.Declaration.Variables) foreach (var variable in declaration.Declaration.Variables)
@ -122,7 +122,7 @@ public sealed class CanonicalJsonConfigurationAnalyzer : DiagnosticAnalyzer
/// <summary> /// <summary>
/// Reports assigning any setting of already declared canonical options. /// Reports assigning any setting of already declared canonical options.
/// </summary> /// </summary>
private void AnalyzeAssignment(SyntaxNodeAnalysisContext context) private static void AnalyzeAssignment(SyntaxNodeAnalysisContext context)
{ {
var assignment = (AssignmentExpressionSyntax)context.Node; var assignment = (AssignmentExpressionSyntax)context.Node;
if (assignment.Left is not MemberAccessExpressionSyntax memberAccess) if (assignment.Left is not MemberAccessExpressionSyntax memberAccess)

View File

@ -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");
}
}