|
| 1 | +using System.Collections.Immutable; |
| 2 | +using System.Linq; |
| 3 | +using Microsoft.CodeAnalysis; |
| 4 | +using Microsoft.CodeAnalysis.CSharp; |
| 5 | +using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 6 | +using Microsoft.CodeAnalysis.Diagnostics; |
| 7 | + |
| 8 | +namespace SqlServerSimulator.Analyzers; |
| 9 | + |
| 10 | +/// <summary> |
| 11 | +/// Flags <c>readonly</c> fields in non-public-API types whose declared type |
| 12 | +/// is a strict supertype (base class or implemented interface) of the |
| 13 | +/// immediately-assigned initializer's static type. |
| 14 | +/// </summary> |
| 15 | +/// <remarks> |
| 16 | +/// <para> |
| 17 | +/// In a non-public type, no API-stability boundary justifies hiding the |
| 18 | +/// concrete shape of an immediately-bound singleton. Declaring the field as |
| 19 | +/// the abstract type forces every same-assembly read to go through virtual |
| 20 | +/// dispatch (and stops the IDE from offering members the concrete type |
| 21 | +/// adds). Declaring the field as the concrete type lets callers see |
| 22 | +/// non-virtual / non-overridden members directly and shrinks the call-site |
| 23 | +/// indirection. |
| 24 | +/// </para> |
| 25 | +/// <para> |
| 26 | +/// The rule fires only when an immediate initializer pins the runtime type |
| 27 | +/// at compile time. Fields without initializers, fields whose initializer |
| 28 | +/// already matches the declared type, and fields whose initializer is a |
| 29 | +/// value type (treating the declared abstract type as a deliberate boxing |
| 30 | +/// boundary) are exempt. Public types are also exempt: the declared type |
| 31 | +/// there is the documented API contract. |
| 32 | +/// </para> |
| 33 | +/// </remarks> |
| 34 | +[DiagnosticAnalyzer(LanguageNames.CSharp)] |
| 35 | +public sealed class WidenedFieldTypeAnalyzer : DiagnosticAnalyzer |
| 36 | +{ |
| 37 | + private static readonly DiagnosticDescriptor Rule = new( |
| 38 | + id: "SSS002", |
| 39 | + title: "Field declared as an abstract type but initialized with a more specific value", |
| 40 | + messageFormat: "Field '{0}' is declared as '{1}' but its initializer produces '{2}'; in non-public type '{3}', declare the field as '{2}' to expose the concrete shape directly", |
| 41 | + category: "Design", |
| 42 | + defaultSeverity: DiagnosticSeverity.Warning, |
| 43 | + isEnabledByDefault: true, |
| 44 | + description: "A readonly field whose declared type is a base class or interface of its immediate initializer's runtime type provides no API-stability benefit when the containing type isn't part of the public API. Declare the field as the concrete type so same-assembly callers see the specific members and avoid virtual dispatch."); |
| 45 | + |
| 46 | + /// <inheritdoc/> |
| 47 | + public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule]; |
| 48 | + |
| 49 | + /// <inheritdoc/> |
| 50 | + public override void Initialize(AnalysisContext context) |
| 51 | + { |
| 52 | + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
| 53 | + context.EnableConcurrentExecution(); |
| 54 | + context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration); |
| 55 | + } |
| 56 | + |
| 57 | + private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context) |
| 58 | + { |
| 59 | + var fieldDecl = (FieldDeclarationSyntax)context.Node; |
| 60 | + |
| 61 | + // Const fields are restricted by the compiler to compile-time |
| 62 | + // primitives that must match the declared type exactly — there's no |
| 63 | + // widening to flag. readonly is the gate; non-readonly fields can |
| 64 | + // legitimately be reassigned to less-specific values later. |
| 65 | + if (fieldDecl.Modifiers.Any(SyntaxKind.ConstKeyword)) |
| 66 | + return; |
| 67 | + if (!fieldDecl.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)) |
| 68 | + return; |
| 69 | + |
| 70 | + foreach (var declarator in fieldDecl.Declaration.Variables) |
| 71 | + { |
| 72 | + if (declarator.Initializer is not { Value: { } initializerExpression }) |
| 73 | + continue; |
| 74 | + |
| 75 | + if (context.SemanticModel.GetDeclaredSymbol(declarator, context.CancellationToken) is not IFieldSymbol field) |
| 76 | + continue; |
| 77 | + |
| 78 | + var containingType = field.ContainingType; |
| 79 | + if (containingType is null || IsEffectivelyPublic(containingType)) |
| 80 | + continue; |
| 81 | + |
| 82 | + if (field.Type is not INamedTypeSymbol declaredType) |
| 83 | + continue; |
| 84 | + |
| 85 | + var initializerType = context.SemanticModel.GetTypeInfo(initializerExpression, context.CancellationToken).Type; |
| 86 | + if (initializerType is not INamedTypeSymbol assignedType) |
| 87 | + continue; |
| 88 | + |
| 89 | + // Same type → no widening; nothing to flag. (Reference equality |
| 90 | + // via SymbolEqualityComparer is the right check; nominal name |
| 91 | + // matches won't appear here because the semantic model returns |
| 92 | + // the same canonical symbol on both sides.) |
| 93 | + if (SymbolEqualityComparer.Default.Equals(declaredType, assignedType)) |
| 94 | + continue; |
| 95 | + |
| 96 | + // Value-typed initializer with a reference-typed field is a |
| 97 | + // deliberate boxing boundary — declaring the field as the value |
| 98 | + // type would change semantics. Leave it alone. |
| 99 | + if (assignedType.IsValueType) |
| 100 | + continue; |
| 101 | + |
| 102 | + // Confirm the declared type is actually a supertype of the |
| 103 | + // assigned type. (Defensive: the code compiled, so this should |
| 104 | + // always hold for a non-equal pair, but null and tuple types |
| 105 | + // can sneak through TypeInfo.) |
| 106 | + if (!IsAssignableTo(assignedType, declaredType)) |
| 107 | + continue; |
| 108 | + |
| 109 | + var location = declarator.Identifier.GetLocation(); |
| 110 | + var diagnostic = Diagnostic.Create( |
| 111 | + Rule, |
| 112 | + location, |
| 113 | + field.Name, |
| 114 | + declaredType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), |
| 115 | + assignedType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), |
| 116 | + containingType.Name); |
| 117 | + context.ReportDiagnostic(diagnostic); |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + /// <summary> |
| 122 | + /// True when <paramref name="source"/> is a strict subtype of |
| 123 | + /// <paramref name="target"/> — either through the base-class chain or |
| 124 | + /// because <paramref name="target"/> is one of <paramref name="source"/>'s |
| 125 | + /// implemented interfaces. |
| 126 | + /// </summary> |
| 127 | + private static bool IsAssignableTo(INamedTypeSymbol source, INamedTypeSymbol target) |
| 128 | + { |
| 129 | + for (var current = source.BaseType; current is not null; current = current.BaseType) |
| 130 | + { |
| 131 | + if (SymbolEqualityComparer.Default.Equals(current, target)) |
| 132 | + return true; |
| 133 | + } |
| 134 | + foreach (var iface in source.AllInterfaces) |
| 135 | + { |
| 136 | + if (SymbolEqualityComparer.Default.Equals(iface, target)) |
| 137 | + return true; |
| 138 | + } |
| 139 | + return false; |
| 140 | + } |
| 141 | + |
| 142 | + /// <summary> |
| 143 | + /// True iff <paramref name="type"/> and every containing type up to the |
| 144 | + /// namespace are <c>public</c>. Mirrors the |
| 145 | + /// <see cref="WrapperPropertyAnalyzer"/> exemption. |
| 146 | + /// </summary> |
| 147 | + private static bool IsEffectivelyPublic(INamedTypeSymbol type) => |
| 148 | + type.DeclaredAccessibility == Accessibility.Public |
| 149 | + && (type.ContainingType is null || IsEffectivelyPublic(type.ContainingType)); |
| 150 | +} |
0 commit comments