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