Skip to content

Commit 29e18a6

Browse files
EvangelinkCopilot
andcommitted
Address review: respect AttributeUsage and align stub with real MSTest
- IsAccessibleFromConsumer now excludes Protected / ProtectedAndInternal. - BuildMethodSignatureKey encodes the method's generic arity. - CollectInheritedAttributes respects AttributeUsage(Inherited = false) on inherited levels and preserves every instance of AllowMultiple = true attributes (e.g. [TestCategory], [DataRow]). - MinimalMSTestStub TestMethodAttribute and DataRowAttribute now declare AttributeUsage(Inherited = false), matching the real MSTest attribute definitions so inheritance behavior is exercised correctly. - Adds regression tests covering protected exclusion, generic arity, AllowMultiple preservation, and Inherited = false propagation rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 517e185 commit 29e18a6

2 files changed

Lines changed: 234 additions & 14 deletions

File tree

src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,28 @@ when IsAccessibleFromConsumer(property):
9494
Attributes: BuildAttributes(typeSymbol.GetAttributes()));
9595
}
9696

97+
// Restricted to accessibilities the emitted helper class (a separate static type
98+
// declared in MSTest.SourceGenerated, not a derived type) can legally call.
99+
// 'protected' and 'private protected' members require the caller to be a derived
100+
// type, so they are excluded; 'protected internal' is included because the internal
101+
// half is satisfied (the generated helper lives in the same assembly).
97102
private static bool IsAccessibleFromConsumer(ISymbol symbol)
98103
=> symbol.DeclaredAccessibility is
99104
Accessibility.Public
100105
or Accessibility.Internal
101-
or Accessibility.Protected
102-
or Accessibility.ProtectedOrInternal
103-
or Accessibility.ProtectedAndInternal;
106+
or Accessibility.ProtectedOrInternal;
104107

105108
private static string BuildMethodSignatureKey(IMethodSymbol method)
106109
{
107110
var sb = new StringBuilder();
108111
sb.Append(method.IsStatic ? "S:" : "I:");
109112
sb.Append(method.Name);
113+
if (method.Arity > 0)
114+
{
115+
sb.Append('`');
116+
sb.Append(method.Arity);
117+
}
118+
110119
sb.Append('(');
111120
bool first = true;
112121
foreach (IParameterSymbol p in method.Parameters)
@@ -228,7 +237,9 @@ private static TestPropertyModel BuildProperty(IPropertySymbol property)
228237

229238
// Mirror the runtime behavior of MemberInfo.GetCustomAttributes(inherit: true): walk the
230239
// overridden-method chain and union attributes, keeping the most-derived application when
231-
// the same attribute type appears on multiple levels.
240+
// the same attribute type appears on multiple levels — but respect
241+
// [AttributeUsage(Inherited = false)] (the attribute is NOT visible past the level it was
242+
// declared on) and [AttributeUsage(AllowMultiple = true)] (every occurrence is kept).
232243
private static ImmutableArray<AttributeData> CollectInheritedAttributes(IMethodSymbol method)
233244
{
234245
ImmutableArray<AttributeData> own = method.GetAttributes();
@@ -239,10 +250,10 @@ private static ImmutableArray<AttributeData> CollectInheritedAttributes(IMethodS
239250

240251
var seen = new HashSet<string>(StringComparer.Ordinal);
241252
ImmutableArray<AttributeData>.Builder builder = ImmutableArray.CreateBuilder<AttributeData>();
242-
AppendUnique(builder, seen, own);
253+
AppendUnique(builder, seen, own, isInheritedLevel: false);
243254
for (IMethodSymbol? baseMethod = method.OverriddenMethod; baseMethod is not null; baseMethod = baseMethod.OverriddenMethod)
244255
{
245-
AppendUnique(builder, seen, baseMethod.GetAttributes());
256+
AppendUnique(builder, seen, baseMethod.GetAttributes(), isInheritedLevel: true);
246257
}
247258

248259
return builder.ToImmutable();
@@ -258,10 +269,10 @@ private static ImmutableArray<AttributeData> CollectInheritedAttributes(IPropert
258269

259270
var seen = new HashSet<string>(StringComparer.Ordinal);
260271
ImmutableArray<AttributeData>.Builder builder = ImmutableArray.CreateBuilder<AttributeData>();
261-
AppendUnique(builder, seen, own);
272+
AppendUnique(builder, seen, own, isInheritedLevel: false);
262273
for (IPropertySymbol? baseProperty = property.OverriddenProperty; baseProperty is not null; baseProperty = baseProperty.OverriddenProperty)
263274
{
264-
AppendUnique(builder, seen, baseProperty.GetAttributes());
275+
AppendUnique(builder, seen, baseProperty.GetAttributes(), isInheritedLevel: true);
265276
}
266277

267278
return builder.ToImmutable();
@@ -270,7 +281,8 @@ private static ImmutableArray<AttributeData> CollectInheritedAttributes(IPropert
270281
private static void AppendUnique(
271282
ImmutableArray<AttributeData>.Builder builder,
272283
HashSet<string> seen,
273-
ImmutableArray<AttributeData> attributes)
284+
ImmutableArray<AttributeData> attributes,
285+
bool isInheritedLevel)
274286
{
275287
foreach (AttributeData attribute in attributes)
276288
{
@@ -279,6 +291,24 @@ private static void AppendUnique(
279291
continue;
280292
}
281293

294+
(bool allowMultiple, bool inherited) = GetAttributeUsage(attributeClass);
295+
296+
// A base-level attribute declared with AttributeUsage(Inherited = false) must
297+
// not leak onto the derived override (matches MemberInfo.GetCustomAttributes(inherit: true)).
298+
if (isInheritedLevel && !inherited)
299+
{
300+
continue;
301+
}
302+
303+
// Attributes declared with AttributeUsage(AllowMultiple = true) may legitimately
304+
// appear several times across the override chain (e.g. [TestCategory], [DataRow])
305+
// — keep every instance instead of collapsing them to one.
306+
if (allowMultiple)
307+
{
308+
builder.Add(attribute);
309+
continue;
310+
}
311+
282312
string key = attributeClass.ToDisplayString(FullyQualifiedFormat);
283313
if (seen.Add(key))
284314
{
@@ -287,6 +317,40 @@ private static void AppendUnique(
287317
}
288318
}
289319

320+
private static (bool AllowMultiple, bool Inherited) GetAttributeUsage(INamedTypeSymbol attributeClass)
321+
{
322+
for (INamedTypeSymbol? current = attributeClass; current is not null; current = current.BaseType)
323+
{
324+
foreach (AttributeData attribute in current.GetAttributes())
325+
{
326+
if (attribute.AttributeClass?.ToDisplayString(FullyQualifiedFormat) != "global::System.AttributeUsageAttribute")
327+
{
328+
continue;
329+
}
330+
331+
bool allowMultiple = false;
332+
bool inherited = true;
333+
foreach (KeyValuePair<string, TypedConstant> named in attribute.NamedArguments)
334+
{
335+
if (named.Key == "AllowMultiple" && named.Value.Value is bool am)
336+
{
337+
allowMultiple = am;
338+
}
339+
else if (named.Key == "Inherited" && named.Value.Value is bool inh)
340+
{
341+
inherited = inh;
342+
}
343+
}
344+
345+
// [AttributeUsage] on a derived attribute class shadows the base per CLI rules;
346+
// stop at the first level where it is found.
347+
return (allowMultiple, inherited);
348+
}
349+
}
350+
351+
return (AllowMultiple: false, Inherited: true);
352+
}
353+
290354
private static EquatableArray<TestParameterModel> BuildParameters(IMethodSymbol method)
291355
{
292356
if (method.Parameters.IsDefaultOrEmpty)

test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs

Lines changed: 161 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting
2828
[System.AttributeUsage(System.AttributeTargets.Class)]
2929
public class TestClassAttribute : System.Attribute { }
3030
31-
[System.AttributeUsage(System.AttributeTargets.Method)]
31+
[System.AttributeUsage(System.AttributeTargets.Method, Inherited = false)]
3232
public class TestMethodAttribute : System.Attribute
3333
{
3434
public TestMethodAttribute() { }
@@ -59,7 +59,7 @@ public class ParallelizeAttribute : System.Attribute
5959
public string? Scope { get; set; }
6060
}
6161
62-
[System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)]
62+
[System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
6363
public class DataRowAttribute : System.Attribute
6464
{
6565
public DataRowAttribute(object? data1) { }
@@ -571,6 +571,9 @@ public virtual void Run() { }
571571
[TestClass]
572572
public class DerivedTests : BaseTests
573573
{
574+
// [TestMethod] is re-applied here because the real attribute is declared
575+
// with AttributeUsage(Inherited = false) and would not be inherited.
576+
[TestMethod]
574577
public override void Run() { }
575578
}
576579
}
@@ -583,9 +586,6 @@ public override void Run() { }
583586
runEntries.Should().Be(1, "the derived override must replace the base entry (not duplicate it)");
584587
registry.Should().Contain("((global::Sample.DerivedTests)instance!).Run();");
585588
registry.Should().NotContain("((global::Sample.BaseTests)instance!).Run();");
586-
587-
// The override does NOT re-apply [TestMethod] but the attribute must still be visible
588-
// via the override chain — matching the runtime semantics of GetCustomAttributes(inherit: true).
589589
registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute");
590590
}
591591

@@ -1032,6 +1032,162 @@ public void Test(string? value) { }
10321032
registry.Should().Contain("new object?[] { (object)null! }");
10331033
}
10341034

1035+
[TestMethod]
1036+
public void Generator_SkipsProtectedAndPrivateProtectedMembers()
1037+
{
1038+
// Generated invokers live in a separate static helper class (not a derived type),
1039+
// so 'protected' and 'private protected' members are not callable from the emitted
1040+
// code. They MUST be excluded from the registry to keep the generated source compiling.
1041+
const string userCode = """
1042+
using Microsoft.VisualStudio.TestTools.UnitTesting;
1043+
1044+
namespace Sample
1045+
{
1046+
public class BaseTests
1047+
{
1048+
[TestMethod]
1049+
protected void ProtectedTest() { }
1050+
1051+
[TestMethod]
1052+
private protected void PrivateProtectedTest() { }
1053+
}
1054+
1055+
[TestClass]
1056+
public class DerivedTests : BaseTests
1057+
{
1058+
[TestMethod]
1059+
public void PublicTest() { }
1060+
}
1061+
}
1062+
""";
1063+
1064+
Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode);
1065+
IEnumerable<Diagnostic> errors = outputCompilation
1066+
.GetDiagnostics()
1067+
.Where(d => d.Severity == DiagnosticSeverity.Error);
1068+
errors.Should().BeEmpty("the generated registry MUST NOT reference protected / private protected members");
1069+
1070+
string registry = outputCompilation
1071+
.SyntaxTrees
1072+
.Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal))
1073+
.ToString();
1074+
1075+
registry.Should().Contain("Name = \"PublicTest\"");
1076+
registry.Should().NotContain("Name = \"ProtectedTest\"");
1077+
registry.Should().NotContain("Name = \"PrivateProtectedTest\"");
1078+
}
1079+
1080+
[TestMethod]
1081+
public void Generator_KeepsAllowMultipleAttributes_AcrossOverrideChain()
1082+
{
1083+
// [TestCategory] is AllowMultiple=true. Collecting attributes across the override chain
1084+
// MUST keep every instance instead of collapsing them by type, otherwise the inherited
1085+
// categories disappear from the registry.
1086+
const string userCode = """
1087+
using Microsoft.VisualStudio.TestTools.UnitTesting;
1088+
1089+
namespace Sample
1090+
{
1091+
public class BaseTests
1092+
{
1093+
[TestMethod]
1094+
[TestCategory("BaseCat")]
1095+
public virtual void Run() { }
1096+
}
1097+
1098+
[TestClass]
1099+
public class DerivedTests : BaseTests
1100+
{
1101+
[TestCategory("DerivedCat")]
1102+
public override void Run() { }
1103+
}
1104+
}
1105+
""";
1106+
1107+
string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
1108+
1109+
registry.Should().Contain("\"BaseCat\"");
1110+
registry.Should().Contain("\"DerivedCat\"");
1111+
}
1112+
1113+
[TestMethod]
1114+
public void Generator_DistinguishesGenericArity_BetweenSameNamedMethods()
1115+
{
1116+
// Methods that differ only in generic arity (e.g. M() vs M<T>()) MUST be treated as
1117+
// distinct in the per-class dedup key, otherwise the generator drops one of them.
1118+
const string userCode = """
1119+
using Microsoft.VisualStudio.TestTools.UnitTesting;
1120+
1121+
namespace Sample
1122+
{
1123+
[TestClass]
1124+
public class GenericArityTests
1125+
{
1126+
[TestMethod]
1127+
public void Run() { }
1128+
1129+
[TestMethod]
1130+
public void Run<T>() { }
1131+
1132+
[TestMethod]
1133+
public void Run<T, U>() { }
1134+
}
1135+
}
1136+
""";
1137+
1138+
Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode);
1139+
IEnumerable<Diagnostic> errors = outputCompilation
1140+
.GetDiagnostics()
1141+
.Where(d => d.Severity == DiagnosticSeverity.Error);
1142+
errors.Should().BeEmpty();
1143+
1144+
string registry = outputCompilation
1145+
.SyntaxTrees
1146+
.Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal))
1147+
.ToString();
1148+
1149+
int occurrences = System.Text.RegularExpressions.Regex
1150+
.Matches(registry, "Name = \"Run\"")
1151+
.Count;
1152+
occurrences.Should().Be(3);
1153+
}
1154+
1155+
[TestMethod]
1156+
public void Generator_DoesNotInherit_TestMethodOrDataRow_OntoOverride()
1157+
{
1158+
// [TestMethod] and [DataRow] are declared with AttributeUsage(Inherited = false) in
1159+
// the real MSTest assembly. An override that does NOT re-apply [TestMethod] is not a
1160+
// test, and inherited [DataRow]s must not leak from the base method onto the override.
1161+
const string userCode = """
1162+
using Microsoft.VisualStudio.TestTools.UnitTesting;
1163+
1164+
namespace Sample
1165+
{
1166+
public class BaseTests
1167+
{
1168+
[TestMethod]
1169+
[DataRow(1)]
1170+
[DataRow(2)]
1171+
public virtual void Run(int x) { }
1172+
}
1173+
1174+
[TestClass]
1175+
public class DerivedTests : BaseTests
1176+
{
1177+
// Override deliberately does not re-apply [TestMethod] or any [DataRow].
1178+
public override void Run(int x) { }
1179+
}
1180+
}
1181+
""";
1182+
1183+
string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
1184+
1185+
// Neither the [TestMethod] nor the inherited [DataRow]s should propagate onto the override.
1186+
registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute()");
1187+
registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute(");
1188+
registry.Should().NotContain("new DataRowModel(");
1189+
}
1190+
10351191
private static string GetRegistry(GeneratorRunResult result)
10361192
=> result.GeneratedSources
10371193
.Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs")

0 commit comments

Comments
 (0)