Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ MSTEST0065 | Usage | Warning | AvoidAssertAreEqualOnCollectionsAnalyzer, [Docume
MSTEST0066 | Design | Info | IgnoreShouldHaveJustificationAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0066)
MSTEST0067 | Usage | Disabled | AvoidThreadSleepAndTaskWaitInTestsAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0067)
MSTEST0068 | Usage | Info | CollectionAssertToAssertAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0068)
MSTEST0070 | Usage | Warning | MemberConditionShouldBeValidAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0070)
3 changes: 2 additions & 1 deletion src/Analyzers/MSTest.Analyzers/Helpers/DiagnosticIds.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace MSTest.Analyzers.Helpers;
Expand Down Expand Up @@ -74,4 +74,5 @@ internal static class DiagnosticIds
public const string AvoidThreadSleepAndTaskWaitInTestsRuleId = "MSTEST0067";
public const string CollectionAssertToAssertRuleId = "MSTEST0068";
// public const string InheritedTestClassAttributeWithSourceGeneratorRuleId = "MSTEST0069"; - // Reserved. Owned by MSTest.SourceGeneration analyzer; don't reuse this ID.
public const string MemberConditionShouldBeValidRuleId = "MSTEST0070";
}
5 changes: 3 additions & 2 deletions src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace MSTest.Analyzers.Helpers;
Expand Down Expand Up @@ -29,6 +29,8 @@ internal static class WellKnownTypeNames
public const string MicrosoftVisualStudioTestToolsUnitTestingGlobalTestInitializeAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.GlobalTestInitializeAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingIgnoreAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.IgnoreAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingInheritanceBehavior = "Microsoft.VisualStudio.TestTools.UnitTesting.InheritanceBehavior";
public const string MicrosoftVisualStudioTestToolsUnitTestingMemberConditionAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.MemberConditionAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingOSConditionAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.OSConditionAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingOwnerAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.OwnerAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingParallelizeAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.ParallelizeAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingPriorityAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.PriorityAttribute";
Comment on lines 31 to 36

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc21eee -- moved MicrosoftVisualStudioTestToolsUnitTestingOSConditionAttribute to its correct alphabetical position (between ...MemberConditionAttribute and ...OwnerAttribute). Review reply handled.

Expand All @@ -42,7 +44,6 @@ internal static class WellKnownTypeNames
public const string MicrosoftVisualStudioTestToolsUnitTestingTestPropertyAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingTimeoutAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.TimeoutAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingWorkItemAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.WorkItemAttribute";
public const string MicrosoftVisualStudioTestToolsUnitTestingOSConditionAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.OSConditionAttribute";

public const string System = "System";
public const string SystemRuntimeInteropServicesRuntimeInformation = "System.Runtime.InteropServices.RuntimeInformation";
Expand Down
344 changes: 344 additions & 0 deletions src/Analyzers/MSTest.Analyzers/MemberConditionShouldBeValidAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Immutable;

using Analyzer.Utilities.Extensions;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

using MSTest.Analyzers.Helpers;

namespace MSTest.Analyzers;

/// <summary>
/// MSTEST0070: <inheritdoc cref="Resources.MemberConditionShouldBeValidTitle"/>.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
public sealed class MemberConditionShouldBeValidAnalyzer : DiagnosticAnalyzer
{
private static readonly LocalizableResourceString Title = new(nameof(Resources.MemberConditionShouldBeValidTitle), Resources.ResourceManager, typeof(Resources));
private static readonly LocalizableResourceString Description = new(nameof(Resources.MemberConditionShouldBeValidDescription), Resources.ResourceManager, typeof(Resources));

/// <inheritdoc cref="Resources.MemberConditionShouldBeValidMessageFormat_MemberNotFound"/>
public static readonly DiagnosticDescriptor MemberNotFoundRule = DiagnosticDescriptorHelper.Create(
DiagnosticIds.MemberConditionShouldBeValidRuleId,
Title,
new LocalizableResourceString(nameof(Resources.MemberConditionShouldBeValidMessageFormat_MemberNotFound), Resources.ResourceManager, typeof(Resources)),
Description,
Category.Usage,
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

/// <inheritdoc cref="Resources.MemberConditionShouldBeValidMessageFormat_MemberNotPublic"/>
public static readonly DiagnosticDescriptor MemberNotPublicRule = MemberNotFoundRule
.WithMessage(new(nameof(Resources.MemberConditionShouldBeValidMessageFormat_MemberNotPublic), Resources.ResourceManager, typeof(Resources)));

/// <inheritdoc cref="Resources.MemberConditionShouldBeValidMessageFormat_MemberNotStatic"/>
public static readonly DiagnosticDescriptor MemberNotStaticRule = MemberNotFoundRule
.WithMessage(new(nameof(Resources.MemberConditionShouldBeValidMessageFormat_MemberNotStatic), Resources.ResourceManager, typeof(Resources)));

/// <inheritdoc cref="Resources.MemberConditionShouldBeValidMessageFormat_MemberWrongKind"/>
public static readonly DiagnosticDescriptor MemberWrongKindRule = MemberNotFoundRule
.WithMessage(new(nameof(Resources.MemberConditionShouldBeValidMessageFormat_MemberWrongKind), Resources.ResourceManager, typeof(Resources)));

/// <inheritdoc cref="Resources.MemberConditionShouldBeValidMessageFormat_MemberWrongReturnType"/>
public static readonly DiagnosticDescriptor MemberWrongReturnTypeRule = MemberNotFoundRule
.WithMessage(new(nameof(Resources.MemberConditionShouldBeValidMessageFormat_MemberWrongReturnType), Resources.ResourceManager, typeof(Resources)));

/// <inheritdoc cref="Resources.MemberConditionShouldBeValidMessageFormat_MethodHasParameters"/>
public static readonly DiagnosticDescriptor MethodHasParametersRule = MemberNotFoundRule
.WithMessage(new(nameof(Resources.MemberConditionShouldBeValidMessageFormat_MethodHasParameters), Resources.ResourceManager, typeof(Resources)));

/// <inheritdoc cref="Resources.MemberConditionShouldBeValidMessageFormat_PropertyNotReadable"/>
public static readonly DiagnosticDescriptor PropertyNotReadableRule = MemberNotFoundRule
.WithMessage(new(nameof(Resources.MemberConditionShouldBeValidMessageFormat_PropertyNotReadable), Resources.ResourceManager, typeof(Resources)));

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(
MemberNotFoundRule,
MemberNotPublicRule,
MemberNotStaticRule,
MemberWrongKindRule,
MemberWrongReturnTypeRule,
MethodHasParametersRule,
PropertyNotReadableRule);

/// <inheritdoc />
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterCompilationStartAction(context =>
{
if (!context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.MicrosoftVisualStudioTestToolsUnitTestingMemberConditionAttribute, out INamedTypeSymbol? conditionAttributeSymbol))
{
return;
}

context.RegisterSymbolAction(
ctx => AnalyzeSymbol(ctx, conditionAttributeSymbol),
SymbolKind.Method,
SymbolKind.NamedType);
});
}

private static void AnalyzeSymbol(SymbolAnalysisContext context, INamedTypeSymbol conditionAttributeSymbol)
{
foreach (AttributeData attribute in context.Symbol.GetAttributes())
{
if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, conditionAttributeSymbol))
{
continue;
}

AnalyzeAttribute(context, attribute);
}
}

private static void AnalyzeAttribute(SymbolAnalysisContext context, AttributeData attribute)
{
if (attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken) is not { } attributeSyntax)
{
return;
}

// Walk the constructor arguments. Across the 4 ctor overloads
// ( (Type, string), (Type, string, params string[]),
// (ConditionMode, Type, string), (ConditionMode, Type, string, params string[]) )
// we can identify the condition type, the first member name, and the optional params array
// by inspecting argument kinds and types.
ITypeSymbol? conditionType = null;
var memberNames = new List<string>();
foreach (TypedConstant argument in attribute.ConstructorArguments)
{
if (argument.IsNull)
{
continue;
}

if (argument.Kind == TypedConstantKind.Type && argument.Value is ITypeSymbol typeValue)
{
conditionType = typeValue;
}
else if (argument.Kind == TypedConstantKind.Primitive && argument.Value is string singleName)
{
memberNames.Add(singleName);
}
else if (argument.Kind == TypedConstantKind.Array)
{
foreach (TypedConstant element in argument.Values.Where(static e => !e.IsNull && e.Value is string))
{
memberNames.Add((string)element.Value!);
}
}
}

if (conditionType is null || memberNames.Count == 0)
{
return;
}

string typeName = conditionType.Name;

// Non-named types (arrays, pointers, function pointers) can't carry user-declared static
// bool members the way [MemberCondition] requires. The runtime will throw
// ``InvalidOperationException`` at first ``IsConditionMet`` access; surface that as
// MSTEST0070 (MemberNotFound) here so the user sees it at edit-time.
if (conditionType is not INamedTypeSymbol namedConditionType)
{
string nonNamedTypeName = string.IsNullOrEmpty(conditionType.Name)
? conditionType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)
: conditionType.Name;
foreach (string memberName in memberNames)
{
if (!string.IsNullOrWhiteSpace(memberName))
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotFoundRule, nonNamedTypeName, memberName));
}
}

return;
}

foreach (string memberName in memberNames)
{
ValidateMember(context, attributeSyntax, namedConditionType, typeName, memberName);
}
}

private static void ValidateMember(SymbolAnalysisContext context, SyntaxNode attributeSyntax, INamedTypeSymbol conditionType, string typeName, string memberName)
{
if (string.IsNullOrWhiteSpace(memberName))
{
// The runtime constructor already throws ArgumentException for null/empty/whitespace
// names. Nothing useful to validate here.
return;
}

ImmutableArray<ISymbol> candidates = LookupMember(conditionType, memberName);
if (candidates.IsEmpty)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotFoundRule, typeName, memberName));
return;
}

// Match the runtime resolution order: GetProperty / GetField / GetMethod with
// BindingFlags.Public | Static | FlattenHierarchy, where GetMethod looks for the
// *parameterless* overload only. If the runtime would bind to a candidate that
// satisfies those filters, prefer it so we don't report a false positive against an
// instance member or a parameterized overload that shadows the real binding target.
ISymbol? selected =
// Property: public + static + non-indexer (runtime rejects indexers).
candidates.OfType<IPropertySymbol>()
.FirstOrDefault(static p => p.DeclaredAccessibility == Accessibility.Public && p.IsStatic && !p.IsIndexer)
// Field: public + static.
?? (ISymbol?)candidates.OfType<IFieldSymbol>()
.FirstOrDefault(static f => f.DeclaredAccessibility == Accessibility.Public && f.IsStatic)
// Method: public + static + ordinary + parameterless.
?? candidates.OfType<IMethodSymbol>()
.FirstOrDefault(static m =>
m.DeclaredAccessibility == Accessibility.Public
&& m.IsStatic
&& m.MethodKind == MethodKind.Ordinary
&& m.Parameters.Length == 0)
// Fallback when no runtime-binding candidate exists: pick the first member by
// kind so the more specific diagnostic (not-public, not-static, etc.) is reported.
?? candidates.FirstOrDefault(static s => s.Kind == SymbolKind.Property)
?? candidates.FirstOrDefault(static s => s.Kind == SymbolKind.Field)
?? candidates.FirstOrDefault(static s => s.Kind == SymbolKind.Method);

if (selected is null)
{
// A nested type, event, or other unsupported member kind is shadowing the name.
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberWrongKindRule, typeName, memberName));
return;
}

switch (selected)
{
case IPropertySymbol property:
ValidateProperty(context, attributeSyntax, typeName, memberName, property);
break;

case IFieldSymbol field:
ValidateField(context, attributeSyntax, typeName, memberName, field);
break;

case IMethodSymbol method:
ValidateMethod(context, attributeSyntax, typeName, memberName, method);
break;

default:
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberWrongKindRule, typeName, memberName));
break;
}
}

private static ImmutableArray<ISymbol> LookupMember(INamedTypeSymbol type, string memberName)
{
// Walk the type hierarchy so inherited public static members are also recognized,
// matching what reflection with `BindingFlags.Public | Static | FlattenHierarchy` would find.
ImmutableArray<ISymbol>.Builder builder = ImmutableArray.CreateBuilder<ISymbol>();
INamedTypeSymbol? current = type;
while (current is not null)
{
foreach (ISymbol member in current.GetMembers(memberName))
{
builder.Add(member);
}

current = current.BaseType;
}

return builder.ToImmutable();
}

private static void ValidateProperty(SymbolAnalysisContext context, SyntaxNode attributeSyntax, string typeName, string memberName, IPropertySymbol property)
{
if (property.IsIndexer)
{
// Indexer properties (e.g. public static bool this[int i] in C# 13+) are rejected by
// the runtime because the attribute requires a *parameterless* readable property.
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberWrongKindRule, typeName, memberName));
return;
}

if (property.DeclaredAccessibility != Accessibility.Public)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotPublicRule, typeName, memberName));
return;
}

if (!property.IsStatic)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotStaticRule, typeName, memberName));
return;
}

if (property.GetMethod is null || property.GetMethod.DeclaredAccessibility != Accessibility.Public)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(PropertyNotReadableRule, typeName, memberName));
return;
}
Comment on lines +281 to +285

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1fccdc3. ValidateProperty now rejects indexer properties (property.IsIndexer) up front with MemberWrongKindRule, matching the runtime InvalidOperationException for properties with index parameters. The runtime-resolution filter in ValidateMember also already excludes indexers when looking for the preferred candidate. Review reply handled.


if (property.Type.SpecialType != SpecialType.System_Boolean)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberWrongReturnTypeRule, typeName, memberName));
}
}

private static void ValidateField(SymbolAnalysisContext context, SyntaxNode attributeSyntax, string typeName, string memberName, IFieldSymbol field)
{
if (field.DeclaredAccessibility != Accessibility.Public)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotPublicRule, typeName, memberName));
return;
}

if (!field.IsStatic)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotStaticRule, typeName, memberName));
return;
}

if (field.Type.SpecialType != SpecialType.System_Boolean)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberWrongReturnTypeRule, typeName, memberName));
}
}

private static void ValidateMethod(SymbolAnalysisContext context, SyntaxNode attributeSyntax, string typeName, string memberName, IMethodSymbol method)
{
if (method.MethodKind != MethodKind.Ordinary)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberWrongKindRule, typeName, memberName));
return;
}

if (method.DeclaredAccessibility != Accessibility.Public)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotPublicRule, typeName, memberName));
return;
}

if (!method.IsStatic)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberNotStaticRule, typeName, memberName));
return;
}

if (method.Parameters.Length > 0)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MethodHasParametersRule, typeName, memberName));
return;
}

if (method.ReturnType.SpecialType != SpecialType.System_Boolean)
{
context.ReportDiagnostic(attributeSyntax.CreateDiagnostic(MemberWrongReturnTypeRule, typeName, memberName));
}
}
}
Loading
Loading