From f1bcfbc2c004724bffe84b98a2bf87126c671bce Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 2 Aug 2026 19:02:56 +0200 Subject: [PATCH] Added rule MWAIS0011 to pin the JSON shape of hashed types --- .../VisualBriefingAssetPlanItem.cs | 7 +- .../VisualBriefingChartSeries.cs | 1 + .../VisualBriefing/VisualBriefingChartSpec.cs | 1 + .../VisualBriefingControlOption.cs | 1 + .../VisualBriefingControlSpec.cs | 1 + .../VisualBriefingEvidenceFact.cs | 1 + .../VisualBriefingEvidenceMetric.cs | 1 + .../VisualBriefingEvidenceTable.cs | 1 + .../VisualBriefingExportManifest.cs | 41 ++--- .../VisualBriefingFormulaNode.cs | 1 + .../VisualBriefingFormulaSpec.cs | 1 + .../VisualBriefingLayoutNode.cs | 1 + .../VisualBriefingPlanComponent.cs | 1 + .../VisualBriefingPlanSection.cs | 1 + .../VisualBriefing/VisualBriefingPlanSlot.cs | 1 + .../VisualBriefingResponsiveColumns.cs | 1 + .../VisualBriefing/VisualBriefingSlotValue.cs | 1 + .../VisualBriefingSourceCoverage.cs | 1 + .../Tools/CanonicalJsonShapeAttribute.cs | 27 ++++ .../AnalyzerReleases.Shipped.md | 3 +- .../SourceCodeRules/Identifier.cs | 1 + .../CanonicalJsonConfigurationAnalyzer.cs | 12 +- .../CanonicalJsonShapeAnalyzer.cs | 149 ++++++++++++++++++ 23 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/CanonicalJsonShapeAttribute.cs create mode 100644 app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonShapeAnalyzer.cs diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssetPlanItem.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssetPlanItem.cs index 918cad94..f2e0cb87 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssetPlanItem.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssetPlanItem.cs @@ -6,23 +6,24 @@ namespace AIStudio.Assistants.VisualBriefing; /// Describes one visual asset without embedding its bytes. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("d05cdc87")] public sealed class VisualBriefingAssetPlanItem { /// /// Gets or sets the stable visual asset identifier. /// [JsonRequired] - public string AssetId { get; set; } = string.Empty; + public string AssetId { get; init; } = string.Empty; /// /// Gets or sets the model's visual description for presentation decisions. /// [JsonRequired] - public string Description { get; set; } = string.Empty; + public string Description { get; init; } = string.Empty; /// /// Gets or sets the target-language text alternative. /// [JsonRequired] - public string AltText { get; set; } = string.Empty; + public string AltText { get; init; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSeries.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSeries.cs index 2954f37b..5d03551c 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSeries.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSeries.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Defines one named numeric series in a chart specification. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("57679f28")] public sealed class VisualBriefingChartSeries { /// Gets or sets the series name. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSpec.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSpec.cs index ae230c3e..6fbfba1c 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSpec.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingChartSpec.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Defines the bounded semantic input for one compiled chart. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("68b2ff45")] public sealed class VisualBriefingChartSpec { /// Gets or sets the owning component identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlOption.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlOption.cs index f0e231af..0ee17de8 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlOption.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlOption.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Defines one value and visible label offered by an interaction control. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("08092336")] public sealed class VisualBriefingControlOption { /// Gets or sets the stored option value. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlSpec.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlSpec.cs index f6f81234..4ba43abc 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlSpec.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingControlSpec.cs @@ -7,6 +7,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Defines one bounded declarative interaction control. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("42306121")] public sealed class VisualBriefingControlSpec { /// Gets or sets the globally unique control identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceFact.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceFact.cs index 2d8fb963..fb79b781 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceFact.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceFact.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Describes one sourced factual statement extracted during evidence analysis. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("7857e7da")] public sealed class VisualBriefingEvidenceFact { /// Gets or sets the stable evidence identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceMetric.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceMetric.cs index 1cdcab55..675e1fb8 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceMetric.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceMetric.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Describes one sourced numeric metric extracted during evidence analysis. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("08d12050")] public sealed class VisualBriefingEvidenceMetric { /// Gets or sets the stable evidence identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceTable.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceTable.cs index cb9f9935..233e9c6f 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceTable.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceTable.cs @@ -7,6 +7,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Describes one sourced table extracted during evidence analysis. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("ad23c5b0")] public sealed class VisualBriefingEvidenceTable { /// Gets or sets the stable evidence identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs index efa6fdb0..dec997dc 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingExportManifest.cs @@ -5,107 +5,108 @@ namespace AIStudio.Assistants.VisualBriefing; /// /// Defines VisualBriefingExportManifest for the visual briefing feature. /// +[CanonicalJsonShape("fc2235e8")] public sealed class VisualBriefingExportManifest { /// /// Defines ArtifactVersion for the visual briefing feature. /// - public int ArtifactVersion { get; set; } = VisualBriefingVersions.ARTIFACT; + public int ArtifactVersion { get; init; } = VisualBriefingVersions.ARTIFACT; /// /// Defines SchemaVersion for the visual briefing feature. /// - public int SchemaVersion { get; set; } = VisualBriefingVersions.SCHEMA; + public int SchemaVersion { get; init; } = VisualBriefingVersions.SCHEMA; /// /// Defines RuntimeVersion for the visual briefing feature. /// - public int RuntimeVersion { get; set; } = VisualBriefingVersions.RUNTIME; + public int RuntimeVersion { get; init; } = VisualBriefingVersions.RUNTIME; /// /// Defines BriefingId for the visual briefing feature. /// - public Guid BriefingId { get; set; } + public Guid BriefingId { get; init; } /// /// Defines RevisionId for the visual briefing feature. /// - public Guid RevisionId { get; set; } + public Guid RevisionId { get; init; } /// /// Defines ParentRevisionId for the visual briefing feature. /// - public Guid? ParentRevisionId { get; set; } + public Guid? ParentRevisionId { get; init; } /// /// Defines Name for the visual briefing feature. /// - public string Name { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; /// /// Defines Author for the visual briefing feature. /// - public string Author { get; set; } = string.Empty; + public string Author { get; init; } = string.Empty; /// /// Defines CreatedAtUtc for the visual briefing feature. /// - public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset CreatedAtUtc { get; init; } /// /// Defines TargetLanguage for the visual briefing feature. /// - public CommonLanguages TargetLanguage { get; set; } + public CommonLanguages TargetLanguage { get; init; } /// /// Defines CustomTargetLanguage for the visual briefing feature. /// - public string CustomTargetLanguage { get; set; } = string.Empty; + public string CustomTargetLanguage { get; init; } = string.Empty; /// /// Defines AudienceProfile for the visual briefing feature. /// - public AudienceProfile AudienceProfile { get; set; } + public AudienceProfile AudienceProfile { get; init; } /// /// Defines AudienceAgeGroup for the visual briefing feature. /// - public AudienceAgeGroup AudienceAgeGroup { get; set; } + public AudienceAgeGroup AudienceAgeGroup { get; init; } /// /// Defines AudienceOrganizationalLevel for the visual briefing feature. /// - public AudienceOrganizationalLevel AudienceOrganizationalLevel { get; set; } + public AudienceOrganizationalLevel AudienceOrganizationalLevel { get; init; } /// /// Defines AudienceExpertise for the visual briefing feature. /// - public AudienceExpertise AudienceExpertise { get; set; } + public AudienceExpertise AudienceExpertise { get; init; } /// /// Defines ShowSourceReferences for the visual briefing feature. /// - public bool ShowSourceReferences { get; set; } + public bool ShowSourceReferences { get; init; } /// /// Defines ProtectionLevel for the visual briefing feature. /// - public VisualBriefingProtectionLevel ProtectionLevel { get; set; } + public VisualBriefingProtectionLevel ProtectionLevel { get; init; } /// /// Defines CustomProtectionLevel for the visual briefing feature. /// - public string CustomProtectionLevel { get; set; } = string.Empty; + public string CustomProtectionLevel { get; init; } = string.Empty; /// /// Defines AIStudioVersion for the visual briefing feature. /// - public string AIStudioVersion { get; set; } = string.Empty; + public string AIStudioVersion { get; init; } = string.Empty; /// /// Defines RuntimeAIStudioVersion for the visual briefing feature. /// - public string RuntimeAIStudioVersion { get; set; } = string.Empty; + public string RuntimeAIStudioVersion { get; init; } = string.Empty; /// /// Gets or sets the SHA-256 hash of the complete standalone HTML document. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaNode.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaNode.cs index f7cad1c1..88fa80c4 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaNode.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaNode.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// /// Defines VisualBriefingFormulaNode for the visual briefing feature. /// +[CanonicalJsonShape("aa29e015")] public sealed class VisualBriefingFormulaNode { /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaSpec.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaSpec.cs index 1441d116..671d5113 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaSpec.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingFormulaSpec.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Connects one deterministic formula tree to a component result slot. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("b644b191")] public sealed class VisualBriefingFormulaSpec { /// Gets or sets the owning component identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingLayoutNode.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingLayoutNode.cs index f2ef678b..93b7a4c3 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingLayoutNode.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingLayoutNode.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Defines one node in the validated bounded presentation layout tree. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("14064835")] public sealed class VisualBriefingLayoutNode { /// Gets or sets the globally unique layout node identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanComponent.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanComponent.cs index 95656094..3be39556 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanComponent.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanComponent.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Plans one semantic component and its evidence and content dependencies. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("bdafbeaf")] public sealed class VisualBriefingPlanComponent { /// Gets or sets the globally unique component identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSection.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSection.cs index aab78638..ae2adfcc 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSection.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSection.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Plans one narrative section and its ordered components. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("91d1394d")] public sealed class VisualBriefingPlanSection { /// Gets or sets the globally unique section identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSlot.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSlot.cs index 9d23aed9..6285332a 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSlot.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanSlot.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Plans one semantic content slot owned by a component. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("04cc2e77")] public sealed class VisualBriefingPlanSlot { /// Gets or sets the globally unique slot identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingResponsiveColumns.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingResponsiveColumns.cs index efaf2f19..2bc84545 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingResponsiveColumns.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingResponsiveColumns.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Defines bounded responsive column counts for one grid layout node. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("92c96e68")] public sealed class VisualBriefingResponsiveColumns { /// Gets or sets the mobile column count. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSlotValue.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSlotValue.cs index 33f5a4c6..08822270 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSlotValue.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSlotValue.cs @@ -7,6 +7,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Assigns a validated JSON value to one planned semantic slot. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("6cfa3f02")] public sealed class VisualBriefingSlotValue { /// Gets or sets the planned slot identifier. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSourceCoverage.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSourceCoverage.cs index 9c2181ab..6ded4b8d 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSourceCoverage.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingSourceCoverage.cs @@ -6,6 +6,7 @@ namespace AIStudio.Assistants.VisualBriefing; /// Records how one source contributed to canonical content. /// [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +[CanonicalJsonShape("b1535c0e")] public sealed class VisualBriefingSourceCoverage { /// diff --git a/app/MindWork AI Studio/Tools/CanonicalJsonShapeAttribute.cs b/app/MindWork AI Studio/Tools/CanonicalJsonShapeAttribute.cs new file mode 100644 index 00000000..ce404de8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/CanonicalJsonShapeAttribute.cs @@ -0,0 +1,27 @@ +namespace AIStudio.Tools; + +/// +/// Pins the JSON shape of a type whose serialized form is hashed into stored data. +/// +/// +/// 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 +/// VisualBriefingPayloadHash, where changing a type breaks the build on its own. +/// Attributes that affect reading rather than writing, such as JsonRequired, are not part of the +/// signature: they cannot change the bytes that were hashed. +/// +/// The expected shape signature, reported by MWAIS0011 whenever it changes. +[AttributeUsage(AttributeTargets.Class)] +public sealed class CanonicalJsonShapeAttribute(string signature) : Attribute +{ + /// + /// Gets the expected shape signature. + /// + public string Signature { get; } = signature; +} \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md index d06cd637..5aeec96c 100644 --- a/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md +++ b/app/SourceCodeRules/SourceCodeRules/AnalyzerReleases.Shipped.md @@ -13,4 +13,5 @@ MWAIS0007 | Usage | Error | EmptyStringAnalyzer MWAIS0008 | Naming | Error | LocalConstantsAnalyzer MWAIS0009 | Usage | Error | StaticServiceProviderCacheAnalyzer - MWAIS0010 | Usage | Error | CanonicalJsonConfigurationAnalyzer \ No newline at end of file + MWAIS0010 | Usage | Error | CanonicalJsonConfigurationAnalyzer + MWAIS0011 | Usage | Error | CanonicalJsonShapeAnalyzer \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/Identifier.cs b/app/SourceCodeRules/SourceCodeRules/Identifier.cs index 4807abd0..cf53127f 100644 --- a/app/SourceCodeRules/SourceCodeRules/Identifier.cs +++ b/app/SourceCodeRules/SourceCodeRules/Identifier.cs @@ -12,4 +12,5 @@ public static class Identifier 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"; } \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonConfigurationAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonConfigurationAnalyzer.cs index 76fab30f..a4afd88b 100644 --- a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonConfigurationAnalyzer.cs +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonConfigurationAnalyzer.cs @@ -35,13 +35,13 @@ public sealed class CanonicalJsonConfigurationAnalyzer : DiagnosticAnalyzer { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(this.AnalyzeProperty, SyntaxKind.PropertyDeclaration); - context.RegisterSyntaxNodeAction(this.AnalyzeField, SyntaxKind.FieldDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeProperty, SyntaxKind.PropertyDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeField, SyntaxKind.FieldDeclaration); 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; 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()); } - private void AnalyzeField(SyntaxNodeAnalysisContext context) + private static void AnalyzeField(SyntaxNodeAnalysisContext context) { var declaration = (FieldDeclarationSyntax)context.Node; foreach (var variable in declaration.Declaration.Variables) @@ -122,7 +122,7 @@ public sealed class CanonicalJsonConfigurationAnalyzer : DiagnosticAnalyzer /// /// Reports assigning any setting of already declared canonical options. /// - private void AnalyzeAssignment(SyntaxNodeAnalysisContext context) + private static void AnalyzeAssignment(SyntaxNodeAnalysisContext context) { var assignment = (AssignmentExpressionSyntax)context.Node; if (assignment.Left is not MemberAccessExpressionSyntax memberAccess) diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonShapeAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonShapeAnalyzer.cs new file mode 100644 index 00000000..d755a3f8 --- /dev/null +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/CanonicalJsonShapeAnalyzer.cs @@ -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); + + /// + /// Renders property types the way they are written in the source, including nullable annotations. + /// + private static readonly SymbolDisplayFormat TYPE_FORMAT = SymbolDisplayFormat.MinimallyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.UseSpecialTypes | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + public override ImmutableArray 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)); + } + + /// + /// Derives the shape signature from everything that changes the serialized bytes. + /// + /// + /// 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. + /// + /// The type to inspect. + /// The signature of the serialized shape. + private static string ComputeSignature(INamedTypeSymbol type) + { + List entries = []; + foreach (var property in type.GetMembers().OfType()) + { + 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)); + } + + /// + /// Gets the JSON name a property is written with. + /// + 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; + } + + /// + /// Gets the ignore behavior of a property, which decides whether it appears at all. + /// + 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; + } + + /// + /// Computes a stable 32-bit FNV-1a hash, rendered as eight lowercase hexadecimal digits. + /// + /// + /// 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. + /// + /// The text to hash. + /// The signature text. + private static string Fnv1A(string value) + { + var hash = 2166136261u; + foreach (var character in value) + { + hash ^= character; + hash *= 16777619u; + } + + return hash.ToString("x8"); + } +} \ No newline at end of file