From 57b9842f9db294e93ef6fe4f05a18c7ba455d846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Sat, 2 May 2026 11:31:01 -0400 Subject: [PATCH] Add MA0195 static-field initialization analyzer Detect reads of static fields from static field initializers before the referenced field is initialized, including cross-partial declarations. Add comprehensive tests for reporting and non-reporting cases (same-part ordering, partial classes, nameof, lambda, and fields without initializers), and update generated documentation/config tables for MA0195. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + docs/README.md | 7 + docs/Rules/MA0195.md | 31 ++++ .../configuration/default.editorconfig | 3 + .../configuration/none.editorconfig | 3 + src/Meziantou.Analyzer/RuleIdentifiers.cs | 1 + ...UseNotYetInitializedStaticFieldAnalyzer.cs | 148 ++++++++++++++++++ ...tYetInitializedStaticFieldAnalyzerTests.cs | 141 +++++++++++++++++ 8 files changed, 335 insertions(+) create mode 100644 docs/Rules/MA0195.md create mode 100644 src/Meziantou.Analyzer/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzer.cs create mode 100644 tests/Meziantou.Analyzer.Test/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzerTests.cs diff --git a/README.md b/README.md index f45673348..06b9fd97f 100755 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0192](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0192.md)|Usage|Use HasFlag instead of bitwise checks|ℹ️|❌|✔️| |[MA0193](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0193.md)|Usage|Use an overload with a MidpointRounding argument|ℹ️|✔️|✔️| |[MA0194](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0194.md)|Usage|Merge is expressions on the same value|ℹ️|✔️|✔️| +|[MA0195](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0195.md)|Usage|Do not use static fields before they are initialized|⚠️|✔️|❌| diff --git a/docs/README.md b/docs/README.md index 7d0c7ba34..204c65590 100755 --- a/docs/README.md +++ b/docs/README.md @@ -193,6 +193,7 @@ |[MA0192](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0192.md)|Usage|Use HasFlag instead of bitwise checks|ℹ️|❌|✔️| |[MA0193](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0193.md)|Usage|Use an overload with a MidpointRounding argument|ℹ️|✔️|✔️| |[MA0194](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0194.md)|Usage|Merge is expressions on the same value|ℹ️|✔️|✔️| +|[MA0195](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0195.md)|Usage|Do not use static fields before they are initialized|⚠️|✔️|❌| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -787,6 +788,9 @@ dotnet_diagnostic.MA0193.severity = suggestion # MA0194: Merge is expressions on the same value dotnet_diagnostic.MA0194.severity = suggestion + +# MA0195: Do not use static fields before they are initialized +dotnet_diagnostic.MA0195.severity = warning ``` # .editorconfig - all rules disabled @@ -1367,4 +1371,7 @@ dotnet_diagnostic.MA0193.severity = none # MA0194: Merge is expressions on the same value dotnet_diagnostic.MA0194.severity = none + +# MA0195: Do not use static fields before they are initialized +dotnet_diagnostic.MA0195.severity = none ``` diff --git a/docs/Rules/MA0195.md b/docs/Rules/MA0195.md new file mode 100644 index 000000000..6765db22f --- /dev/null +++ b/docs/Rules/MA0195.md @@ -0,0 +1,31 @@ +# MA0195 - Do not use static fields before they are initialized + +Source: [DoNotUseNotYetInitializedStaticFieldAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzer.cs) + + +Static fields are initialized in declaration order. Reading another static field in a field initializer can observe the default value when that field is declared later. + +This rule reports references to static fields from static field initializers when: + +- the referenced field is declared later in the same partial declaration, or +- the referenced field is declared in another partial declaration of the same type. + +Fields without an explicit initializer are ignored. + +````csharp +public class Example +{ + public static readonly bool[] Both = new[] { P1, P2 }; // MA0195 + public static readonly bool P1 = true; + public static readonly bool P2 = false; +} +```` + +````csharp +public class Example +{ + public static readonly bool P1 = true; + public static readonly bool P2 = false; + public static readonly bool[] Both = new[] { P1, P2 }; // compliant +} +```` diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index 2599a3d3b..02160af90 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -577,3 +577,6 @@ dotnet_diagnostic.MA0193.severity = suggestion # MA0194: Merge is expressions on the same value dotnet_diagnostic.MA0194.severity = suggestion + +# MA0195: Do not use static fields before they are initialized +dotnet_diagnostic.MA0195.severity = warning diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 5cd3cf82a..214421fc3 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -577,3 +577,6 @@ dotnet_diagnostic.MA0193.severity = none # MA0194: Merge is expressions on the same value dotnet_diagnostic.MA0194.severity = none + +# MA0195: Do not use static fields before they are initialized +dotnet_diagnostic.MA0195.severity = none diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index b0cc28121..88e735f99 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -194,6 +194,7 @@ internal static class RuleIdentifiers public const string UseHasFlagMethod = "MA0192"; public const string UseAnOverloadThatHasMidpointRounding = "MA0193"; public const string MergeIsPatternChecks = "MA0194"; + public const string DoNotUseNotYetInitializedStaticField = "MA0195"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzer.cs b/src/Meziantou.Analyzer/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzer.cs new file mode 100644 index 000000000..6c7a95b17 --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzer.cs @@ -0,0 +1,148 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Meziantou.Analyzer.Rules; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DoNotUseNotYetInitializedStaticFieldAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.DoNotUseNotYetInitializedStaticField, + title: "Do not use static fields before they are initialized", + messageFormat: "Static field '{0}' may not be initialized yet", + RuleCategories.Usage, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.DoNotUseNotYetInitializedStaticField)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + + context.RegisterCompilationStartAction(context => + { + var fieldDeclarationInfos = new ConcurrentDictionary(SymbolEqualityComparer.Default); + context.RegisterOperationAction(context => AnalyzeFieldReference(context, fieldDeclarationInfos), OperationKind.FieldReference); + }); + } + + private static void AnalyzeFieldReference(OperationAnalysisContext context, ConcurrentDictionary fieldDeclarationInfos) + { + var fieldReferenceOperation = (IFieldReferenceOperation)context.Operation; + if (fieldReferenceOperation.IsInNameofOperation()) + return; + + if (IsInDeferredExecutionContext(fieldReferenceOperation)) + return; + + var referencedField = fieldReferenceOperation.Field; + if (referencedField is not { IsImplicitlyDeclared: false, IsStatic: true, IsConst: false }) + return; + + if (!TryGetContainingFieldInitializerField(fieldReferenceOperation, out var currentField)) + return; + + if (!referencedField.ContainingType.IsEqualTo(currentField.ContainingType)) + return; + + if (referencedField.IsEqualTo(currentField)) + return; + + var currentFieldInfo = GetFieldDeclarationInfo(currentField, fieldDeclarationInfos, context.CancellationToken); + if (currentFieldInfo is null) + return; + + var referencedFieldInfo = GetFieldDeclarationInfo(referencedField, fieldDeclarationInfos, context.CancellationToken); + if (referencedFieldInfo is null || referencedFieldInfo.Value.Initializer is null) + return; + + if (!ShouldReport(currentFieldInfo.Value, referencedFieldInfo.Value)) + return; + + context.ReportDiagnostic(Rule, fieldReferenceOperation, referencedField.Name); + } + + private static bool IsInDeferredExecutionContext(IOperation operation) + { + foreach (var ancestor in operation.Ancestors()) + { + if (ancestor is IAnonymousFunctionOperation or ILocalFunctionOperation) + return true; + } + + return false; + } + + private static bool TryGetContainingFieldInitializerField(IOperation operation, [NotNullWhen(true)] out IFieldSymbol? field) + { + foreach (var ancestor in operation.Ancestors()) + { + if (ancestor is IFieldInitializerOperation fieldInitializerOperation) + { + var initializedField = fieldInitializerOperation.InitializedFields.FirstOrDefault(field => field is { IsImplicitlyDeclared: false, IsStatic: true, IsConst: false }); + if (initializedField is not null) + { + field = initializedField; + return true; + } + } + } + + field = null; + return false; + } + + private static bool ShouldReport(FieldDeclarationInfo currentField, FieldDeclarationInfo referencedField) + { + if (!currentField.IsInSamePartialDeclarationAs(referencedField)) + return true; + + return referencedField.DeclaratorStart > currentField.DeclaratorStart; + } + + private static FieldDeclarationInfo? GetFieldDeclarationInfo(IFieldSymbol field, ConcurrentDictionary cache, CancellationToken cancellationToken) + { + if (cache.TryGetValue(field, out var result)) + return result; + + result = CreateFieldDeclarationInfo(field, cancellationToken); + cache.TryAdd(field, result); + return result; + } + + private static FieldDeclarationInfo? CreateFieldDeclarationInfo(IFieldSymbol field, CancellationToken cancellationToken) + { + if (field.DeclaringSyntaxReferences is not [var syntaxReference]) + return null; + + if (syntaxReference.GetSyntax(cancellationToken) is not VariableDeclaratorSyntax variableDeclarator) + return null; + + if (variableDeclarator.FirstAncestorOrSelf() is not TypeDeclarationSyntax typeDeclaration) + return null; + + return new( + variableDeclarator.SyntaxTree, + typeDeclaration.SpanStart, + variableDeclarator.SpanStart, + variableDeclarator.Initializer); + } + + private readonly record struct FieldDeclarationInfo(SyntaxTree SyntaxTree, int TypeDeclarationSpanStart, int DeclaratorStart, EqualsValueClauseSyntax? Initializer) + { + public bool IsInSamePartialDeclarationAs(FieldDeclarationInfo other) + { + return TypeDeclarationSpanStart == other.TypeDeclarationSpanStart && SyntaxTree == other.SyntaxTree; + } + } +} diff --git a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzerTests.cs new file mode 100644 index 000000000..30e0f4f50 --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseNotYetInitializedStaticFieldAnalyzerTests.cs @@ -0,0 +1,141 @@ +using Meziantou.Analyzer.Rules; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class DoNotUseNotYetInitializedStaticFieldAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer(); + } + + [Fact] + public async Task ReportDiagnostic_WhenReferencingLaterFieldInSamePart() + { + await CreateProjectBuilder() + .WithSourceCode(""" + class Sample + { + private static readonly bool[] Values = new[] { [|P1|], [|P2|] }; + private static readonly bool P1 = true; + private static readonly bool P2 = false; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task NoDiagnostic_WhenReferencingEarlierFieldInSamePart() + { + await CreateProjectBuilder() + .WithSourceCode(""" + class Sample + { + private static readonly bool P1 = true; + private static readonly bool P2 = false; + private static readonly bool[] Values = new[] { P1, P2 }; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task ReportDiagnostic_WhenReferencingFieldFromAnotherPartialDeclaration() + { + await CreateProjectBuilder() + .WithSourceCode(""" + partial class Sample + { + private static readonly bool P1 = true; + } + + partial class Sample + { + private static readonly bool[] Values = new[] { [|P1|] }; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task NoDiagnostic_WhenPartialDeclarationsOnlyReferenceEarlierFieldsInSamePart() + { + await CreateProjectBuilder() + .WithSourceCode(""" + partial class Sample + { + private static readonly int P1 = 1; + private static readonly int[] Values1 = new[] { P1 }; + } + + partial class Sample + { + private static readonly int P2 = 2; + private static readonly int[] Values2 = new[] { P2 }; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task NoDiagnostic_WhenReferencingFieldFromAnotherPartialDeclarationWithoutInitializer() + { + await CreateProjectBuilder() + .WithSourceCode(""" + partial class Sample + { + private static readonly int Other; + } + + partial class Sample + { + private static readonly int Value = Other; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task NoDiagnostic_WhenReferencedFieldHasNoInitializer() + { + await CreateProjectBuilder() + .WithSourceCode(""" + class Sample + { + private static readonly int Value = Other; + private static readonly int Other; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task NoDiagnostic_WhenReferenceIsInNameof() + { + await CreateProjectBuilder() + .WithSourceCode(""" + class Sample + { + private static readonly string Value = nameof(Other); + private static readonly int Other = 42; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task NoDiagnostic_WhenReferenceIsInLambda() + { + await CreateProjectBuilder() + .WithSourceCode(""" + class Sample + { + private static readonly System.Func ValueFactory = () => Other; + private static readonly int Other = 42; + } + """) + .ValidateAsync(); + } +}