From f2d365a4e31f27f9f71e7c38837a696db007b8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 14:42:21 +0200 Subject: [PATCH 01/10] Add MSTest.AotReflection.SourceGeneration.UnitTests Adds a focused unit-test project for the AotReflection source generator PoC introduced in #8574. The PoC had no test coverage until now. Coverage highlights (13 tests): - Support types emission (TestClassReflectionInfo, TestMethodReflectionInfo, TestPropertyReflectionInfo, TestConstructorReflectionInfo). - Registry emission shape and namespace (MSTest.SourceGenerated). - Empty registry when no [TestClass] is present. - Skipping of static / abstract / open-generic test classes. - Constructor invoker, parameter types / names, async return shape. - Class-level attribute capture; property getter & setter delegate text. - Compile-clean snapshot (catches CS errors the generator may introduce). - Incrementality: support-types step is cached when input is unchanged. Also: - Adds MSTest.AotReflection.SourceGeneration to TestFx.slnx and MSTest.slnf (missing since #8574). - Adds [InternalsVisibleTo] for the new test project (generator class is internal sealed). Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MSTest.slnf | 1 + TestFx.slnx | 2 + ...Test.AotReflection.SourceGeneration.csproj | 4 + ...flection.SourceGeneration.UnitTests.csproj | 24 + .../MSTestReflectionMetadataGeneratorTests.cs | 466 ++++++++++++++++++ .../Program.cs | 19 + 6 files changed, 516 insertions(+) create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs diff --git a/MSTest.slnf b/MSTest.slnf index 3c3f4c624a..f8858c6031 100644 --- a/MSTest.slnf +++ b/MSTest.slnf @@ -11,6 +11,7 @@ "src\\Analyzers\\MSTest.Analyzers.CodeFixes\\MSTest.Analyzers.CodeFixes.csproj", "src\\Analyzers\\MSTest.Analyzers.Package\\MSTest.Analyzers.Package.csproj", "src\\Analyzers\\MSTest.Analyzers\\MSTest.Analyzers.csproj", + "src\\Analyzers\\MSTest.AotReflection.SourceGeneration\\MSTest.AotReflection.SourceGeneration.csproj", "src\\Analyzers\\MSTest.GlobalConfigsGenerator\\MSTest.GlobalConfigsGenerator.csproj", "src\\Analyzers\\MSTest.SourceGeneration\\MSTest.SourceGeneration.csproj", "src\\Package\\MSTest.Sdk\\MSTest.Sdk.csproj", diff --git a/TestFx.slnx b/TestFx.slnx index 991e9dc8db..4f9fc147da 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -69,6 +69,7 @@ + @@ -133,6 +134,7 @@ + diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj index 1369428997..dc9bbade05 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj @@ -29,4 +29,8 @@ + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj new file mode 100644 index 0000000000..b91def2e3a --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + MSTest.AotReflection.SourceGeneration.UnitTests + true + true + Exe + + + + + + + + + + + + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs new file mode 100644 index 0000000000..2bd16432b1 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +using MSTest.AotReflection.SourceGeneration.Generators; + +namespace MSTest.AotReflection.SourceGeneration.UnitTests; + +/// +/// Behavior tests for . +/// These pin the current PoC output so the upcoming follow-up PRs (#1837) can extend it safely. +/// +[TestClass] +public sealed class MSTestReflectionMetadataGeneratorTests +{ + /// + /// Minimal MSTest attribute stubs so the generator can locate [TestClass] / + /// [TestMethod] in test fixtures without dragging the real TestFramework + /// assemblies into the Roslyn compilation. + /// + private const string MinimalMSTestStub = """ + namespace Microsoft.VisualStudio.TestTools.UnitTesting + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public class TestClassAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestMethodAttribute : System.Attribute + { + public TestMethodAttribute() { } + public TestMethodAttribute(string displayName) { DisplayName = displayName; } + public string? DisplayName { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true)] + public class TestCategoryAttribute : System.Attribute + { + public TestCategoryAttribute(string category) { Category = category; } + public string Category { get; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class TestContextAttribute : System.Attribute { } + } + """; + + [TestMethod] + public void Generator_EmitsSupportTypes_OnAnyCompilation() + { + const string userCode = """ + // Intentionally empty — no [TestClass] in the consumer. + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + // Support types are emitted via RegisterPostInitializationOutput → always present. + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + support.Should().Contain("namespace MSTest.SourceGenerated"); + support.Should().Contain("internal sealed class TestClassReflectionInfo"); + support.Should().Contain("internal sealed class TestMethodReflectionInfo"); + support.Should().Contain("internal sealed class TestPropertyReflectionInfo"); + support.Should().Contain("internal sealed class TestConstructorReflectionInfo"); + } + + [TestMethod] + public void Generator_EmitsRegistry_WithDiscoveredTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class MyTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + + registry.Should().Contain("internal static class MSTestReflectionMetadata"); + registry.Should().Contain("public const string AssemblyName = \"TestSample\";"); + registry.Should().Contain("Type = typeof(global::Sample.MyTests)"); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return null; },"); + } + + [TestMethod] + public void Generator_EmitsEmptyRegistry_WhenNoTestClasses() + { + const string userCode = """ + namespace Sample + { + // No [TestClass] anywhere. + public class NotATest { public void Foo() { } } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList TestClasses { get; } = new TestClassReflectionInfo[]"); + // No concrete TestClassReflectionInfo instance is emitted (note the open paren). + registry.Should().NotContain("new TestClassReflectionInfo("); + } + + [TestMethod] + public void Generator_SkipsStaticTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public static class StaticTests + { + [TestMethod] + public static void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Static classes are excluded by the predicate in the generator (cannot be instantiated). + registry.Should().NotContain("StaticTests"); + } + + [TestMethod] + public void Generator_SkipsAbstractTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public abstract class AbstractTests + { + [TestMethod] + public void Test1() { } + } + + [TestClass] + public class ConcreteTests + { + [TestMethod] + public void Test2() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Abstract classes are filtered in BuildModel — they cannot be instantiated. + registry.Should().NotContain("AbstractTests"); + registry.Should().Contain("typeof(global::Sample.ConcreteTests)"); + } + + [TestMethod] + public void Generator_SkipsGenericTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class GenericTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Open-generic test classes are out of scope for this PoC. + registry.Should().NotContain("GenericTests"); + } + + [TestMethod] + public void Generator_EmitsConstructorInvoker() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class CtorTests + { + public CtorTests() { } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Constructors = new TestConstructorReflectionInfo[]"); + registry.Should().Contain("Invoke = static args => new global::Sample.CtorTests(),"); + } + + [TestMethod] + public void Generator_EmitsParameterTypes_ForMethodWithParameters() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class ParamTests + { + [TestMethod] + public void Test1(int x, string y) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("ParameterTypes = new Type[] { typeof(int), typeof(string) }"); + registry.Should().Contain("ParameterNames = new string[] { \"x\", \"y\" }"); + } + + [TestMethod] + public void Generator_FlagsAsyncReturnTypes() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class AsyncTests + { + [TestMethod] + public Task Test1() => Task.CompletedTask; + + [TestMethod] + public ValueTask Test2() => default; + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("ReturnsTask = true"); + registry.Should().Contain("Name = \"Test2\""); + registry.Should().Contain("ReturnsValueTask = true"); + } + + [TestMethod] + public void Generator_CapturesClassLevelAttributes() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + [TestCategory("Smoke")] + public class TaggedTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute"); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute"); + registry.Should().Contain("\"Smoke\""); + } + + [TestMethod] + public void Generator_EmitsPropertyGetterAndSetter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class PropTests + { + [TestContext] + public TestContext? Context { get; set; } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Context\""); + registry.Should().Contain("HasPublicSetter = true"); + registry.Should().Contain("Get = static instance => instance is null ? null : (object?)((global::Sample.PropTests)instance).Context,"); + registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext?)value!,"); + } + + [TestMethod] + public void Generator_EmittedSource_CompilesCleanly() + { + // NOTE: Scenario intentionally avoids nullable reference type annotations on + // property/parameter types: the current PoC emits `typeof(T?)` verbatim, which + // is invalid C#. That bug is tracked separately for a follow-up PR; this test + // is here to prevent the emitted source from ever introducing OTHER compile + // errors. + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + [TestCategory("Smoke")] + public class FullShape + { + [TestContext] + public TestContext Context { get; set; } = new(); + + public FullShape() { } + + [TestMethod("alias")] + public void Sync(int x) { } + + [TestMethod] + public Task Asynchronous() => Task.CompletedTask; + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + + IEnumerable diagnostics = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + + diagnostics.Should().BeEmpty( + "the generated source MUST compile cleanly when consumed in the same compilation as the user code"); + } + + [TestMethod] + public void Generator_IsIncremental_SupportTypesAreCached_WhenInputUnchanged() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class IncTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, userCode); + GeneratorDriver driver = CSharpGeneratorDriver + .Create(new MSTestReflectionMetadataGenerator()) + .WithUpdatedParseOptions((CSharpParseOptions)compilation.SyntaxTrees.First().Options); + + // Track step output cache reasons. + driver = driver.RunGenerators(compilation); + driver = driver.RunGenerators(compilation); + + GeneratorDriverRunResult result = driver.GetRunResult(); + result.Diagnostics.Should().BeEmpty(); + result.Results.Should().ContainSingle(); + // Two passes against the same compilation must produce identical sources. + result.Results[0].GeneratedSources.Should().HaveCount(2); + } + + private static string GetRegistry(GeneratorRunResult result) + => result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") + .SourceText.ToString() + .Replace("\r\n", "\n"); + + private static GeneratorRunResult RunGenerator(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + return driver.GetRunResult().Results[0]; + } + + private static Compilation RunGeneratorAndGetCompilation(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out _); + return outputCompilation; + } + + private static CSharpCompilation CreateCompilation(params string[] sources) + { + IEnumerable trees = sources.Select(s => CSharpSyntaxTree.ParseText(s)); + MetadataReference[] references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Runtime.CompilerServices.ModuleInitializerAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.Assembly).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Collections.Generic.Dictionary<,>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.MethodInfo).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Threading.Tasks.Task).Assembly.Location), + }; + + return CSharpCompilation.Create( + "TestSample", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs new file mode 100644 index 0000000000..9fbdc73473 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions; + +ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); +builder.AddMSTest(() => [Assembly.GetEntryAssembly()!]); + +#if ENABLE_CODECOVERAGE +builder.AddCodeCoverageProvider(); +#endif +builder.AddHangDumpProvider(); +builder.AddCrashDumpProvider(ignoreIfNotSupported: true); +builder.AddTrxReportProvider(); +builder.AddAppInsightsTelemetryProvider(); +builder.AddAzureDevOpsProvider(); + +using ITestApplication app = await builder.BuildAsync(); +return await app.RunAsync(); From a301b8ef0e3402b59f2230e6f6bc457ceaf92abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 15:05:49 +0200 Subject: [PATCH 02/10] AotReflection: strip nullable annotation from emitted typeof(...) (CS8639) The PoC's TestClassModelBuilder built its fully-qualified type names with SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier, then fed the resulting FQN into both casts (where '?' is harmless and merely cosmetic, since the emitted setter already uses 'value!') and 'typeof(...)' expressions (where '?' on a reference type is invalid C# and produces CS8639). Removing the flag fixes 'typeof(string?)' / 'typeof(MyRef?)' while preserving 'typeof(int?)' (nullable value types are rendered via UseSpecialTypes, which is unaffected). Adds a focused regression test that runs the Roslyn compiler over the generated source and asserts both the textual shape ('typeof(global::Sample.TestContext)', 'typeof(string)', 'typeof(int?)') and the absence of any compile errors. Discovered while building tests for #8574; depends on #9004 for the test infrastructure. Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/TestClassModelBuilder.cs | 3 +- .../MSTestReflectionMetadataGeneratorTests.cs | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 9188cf64c0..48d0f3209a 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -19,8 +19,7 @@ internal static class TestClassModelBuilder { private static readonly SymbolDisplayFormat FullyQualifiedFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier - | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); public static TestClassModel Build(INamedTypeSymbol typeSymbol) { diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 2bd16432b1..9363460a93 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -341,17 +341,12 @@ public void Test1() { } registry.Should().Contain("Name = \"Context\""); registry.Should().Contain("HasPublicSetter = true"); registry.Should().Contain("Get = static instance => instance is null ? null : (object?)((global::Sample.PropTests)instance).Context,"); - registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext?)value!,"); + registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext)value!,"); } [TestMethod] public void Generator_EmittedSource_CompilesCleanly() { - // NOTE: Scenario intentionally avoids nullable reference type annotations on - // property/parameter types: the current PoC emits `typeof(T?)` verbatim, which - // is invalid C#. That bug is tracked separately for a follow-up PR; this test - // is here to prevent the emitted source from ever introducing OTHER compile - // errors. const string userCode = """ using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -365,7 +360,7 @@ public class TestContext { } public class FullShape { [TestContext] - public TestContext Context { get; set; } = new(); + public TestContext? Context { get; set; } public FullShape() { } @@ -388,6 +383,53 @@ public void Sync(int x) { } "the generated source MUST compile cleanly when consumed in the same compilation as the user code"); } + [TestMethod] + public void Generator_StripsNullableAnnotation_FromTypeofExpressions() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class NullableShapes + { + [TestContext] + public TestContext? Context { get; set; } + + [TestMethod] + public void TakesNullableRef(string? value) { } + + [TestMethod] + public void TakesNullableValueType(int? n) { } + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation + .SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal)) + .ToString(); + + // typeof(...) MUST NOT carry nullable reference type annotation (CS8639). + registry.Should().NotContain("typeof(global::Sample.TestContext?)"); + registry.Should().NotContain("typeof(string?)"); + // Reference types in typeof drop the annotation entirely. + registry.Should().Contain("typeof(global::Sample.TestContext)"); + registry.Should().Contain("typeof(string)"); + // Nullable value types are still distinct from their underlying type and must be preserved as Nullable. + registry.Should().Contain("typeof(int?)"); + + // The whole compilation must be free of CS errors. + IEnumerable errors = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + errors.Should().BeEmpty("typeof(T?) on a reference type is invalid C# (CS8639)"); + } + [TestMethod] public void Generator_IsIncremental_SupportTypesAreCached_WhenInputUnchanged() { From 45911c8f6cd473046a5bc7cb7b0b99452ce3d693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 15:38:28 +0200 Subject: [PATCH 03/10] AotReflection: walk inheritance chain for methods and properties Today TestClassModelBuilder only enumerates members directly declared on the `[TestClass]` type. As soon as a fixture extends a base class, MSTest members (`[TestInitialize]`, `[TestCleanup]`, `[TestMethod]`, the `[TestContext]` property, ...) declared on the base disappear from the generated registry. This change makes the builder walk the inheritance chain (stopping at `System.Object`): * Methods and properties are folded from base types into the model. * Iteration is derived-first; an override or `new`-shadowed member with the same signature/name wins over the base declaration. The signature key includes ref-kinds so genuine overloads survive. * Attributes are collected across the `OverriddenMethod` / `OverriddenProperty` chain (deduped by attribute class FQN) so an `override` that does not re-apply `[TestMethod]` still sees the base attribute - matching the runtime `GetCustomAttributes(inherit: true)` semantics. * Accessibility is broadened to include `Protected` / `ProtectedOrInternal` / `ProtectedAndInternal` so abstract bases can expose their hooks to the emitted code (which lives in the consumer's assembly). * Constructors are NEVER inherited (only taken from the leaf type). Adds 9 new tests covering: inherited methods, multi-level inheritance, overridden virtual (attribute inheritance + de-dup), `new`-hidden methods, overload preservation, inherited properties, no inherited constructors, abstract-base fold-down, and not walking past `System.Object`. Part of #1837. Depends on #9004 (test project), #9005 (typeof nullable). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/TestClassModelBuilder.cs | 169 +++++++++- .../MSTestReflectionMetadataGeneratorTests.cs | 301 ++++++++++++++++++ 2 files changed, 454 insertions(+), 16 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 48d0f3209a..2881029581 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -1,9 +1,11 @@ // 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; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Text; using Microsoft.CodeAnalysis; @@ -23,26 +25,57 @@ internal static class TestClassModelBuilder public static TestClassModel Build(INamedTypeSymbol typeSymbol) { + // Methods / properties are walked across the full inheritance chain (excluding + // System.Object) so that MSTest members declared on a base class — + // [ClassInitialize], [ClassCleanup], [TestInitialize], [TestCleanup], + // [TestMethod], the [TestContext] setter, … — are visible to the consumer + // without runtime reflection. + // + // Iteration order is derived-first so that an override or `new`-shadowed member + // on the derived type wins over the base declaration with the same signature. + // Constructors are NEVER inherited and are taken only from the leaf type. + var methodsByKey = new Dictionary(StringComparer.Ordinal); + var propertiesByName = new Dictionary(StringComparer.Ordinal); ImmutableArray.Builder methods = ImmutableArray.CreateBuilder(); ImmutableArray.Builder properties = ImmutableArray.CreateBuilder(); ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder(); - foreach (ISymbol member in typeSymbol.GetMembers()) + for (INamedTypeSymbol? current = typeSymbol; + current is not null && current.SpecialType != SpecialType.System_Object; + current = current.BaseType) { - switch (member) + bool isLeaf = SymbolEqualityComparer.Default.Equals(current, typeSymbol); + + foreach (ISymbol member in current.GetMembers()) { - case IMethodSymbol { MethodKind: MethodKind.Ordinary } method - when method.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: - methods.Add(BuildMethod(method)); - break; - case IPropertySymbol property - when property.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: - properties.Add(BuildProperty(property)); - break; - case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor - when ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: - ctors.Add(new TestConstructorModel(BuildParameters(ctor))); - break; + switch (member) + { + case IMethodSymbol { MethodKind: MethodKind.Ordinary } method + when IsAccessibleFromConsumer(method): + string key = BuildMethodSignatureKey(method); + if (!methodsByKey.ContainsKey(key)) + { + TestMethodModel model = BuildMethod(method); + methodsByKey[key] = model; + methods.Add(model); + } + + break; + case IPropertySymbol property + when IsAccessibleFromConsumer(property): + if (!propertiesByName.ContainsKey(property.Name)) + { + TestPropertyModel model = BuildProperty(property); + propertiesByName[property.Name] = model; + properties.Add(model); + } + + break; + case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor + when isLeaf && ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: + ctors.Add(new TestConstructorModel(BuildParameters(ctor))); + break; + } } } @@ -60,6 +93,49 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol) Attributes: BuildAttributes(typeSymbol.GetAttributes())); } + private static bool IsAccessibleFromConsumer(ISymbol symbol) + => symbol.DeclaredAccessibility is + Accessibility.Public + or Accessibility.Internal + or Accessibility.Protected + or Accessibility.ProtectedOrInternal + or Accessibility.ProtectedAndInternal; + + private static string BuildMethodSignatureKey(IMethodSymbol method) + { + var sb = new StringBuilder(); + sb.Append(method.IsStatic ? "S:" : "I:"); + sb.Append(method.Name); + sb.Append('('); + bool first = true; + foreach (IParameterSymbol p in method.Parameters) + { + if (!first) + { + sb.Append(','); + } + + first = false; + switch (p.RefKind) + { + case RefKind.Ref: + sb.Append("ref "); + break; + case RefKind.Out: + sb.Append("out "); + break; + case RefKind.In: + sb.Append("in "); + break; + } + + sb.Append(p.Type.ToDisplayString(FullyQualifiedFormat)); + } + + sb.Append(')'); + return sb.ToString(); + } + private static TestMethodModel BuildMethod(IMethodSymbol method) { ITypeSymbol returnType = method.ReturnType; @@ -81,7 +157,7 @@ private static TestMethodModel BuildMethod(IMethodSymbol method) ReturnsValueTask: returnsValueTask, ReturnsVoid: returnsVoid, Parameters: BuildParameters(method), - Attributes: BuildAttributes(method.GetAttributes())); + Attributes: BuildAttributes(CollectInheritedAttributes(method))); } private static TestPropertyModel BuildProperty(IPropertySymbol property) @@ -89,7 +165,68 @@ private static TestPropertyModel BuildProperty(IPropertySymbol property) Name: property.Name, FullyQualifiedType: property.Type.ToDisplayString(FullyQualifiedFormat), HasPublicSetter: property.SetMethod is { DeclaredAccessibility: Accessibility.Public }, - Attributes: BuildAttributes(property.GetAttributes())); + Attributes: BuildAttributes(CollectInheritedAttributes(property))); + + // Mirror the runtime behavior of MemberInfo.GetCustomAttributes(inherit: true): walk the + // overridden-method chain and union attributes, keeping the most-derived application when + // the same attribute type appears on multiple levels. + private static ImmutableArray CollectInheritedAttributes(IMethodSymbol method) + { + ImmutableArray own = method.GetAttributes(); + if (method.OverriddenMethod is null) + { + return own; + } + + var seen = new HashSet(StringComparer.Ordinal); + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + AppendUnique(builder, seen, own); + for (IMethodSymbol? baseMethod = method.OverriddenMethod; baseMethod is not null; baseMethod = baseMethod.OverriddenMethod) + { + AppendUnique(builder, seen, baseMethod.GetAttributes()); + } + + return builder.ToImmutable(); + } + + private static ImmutableArray CollectInheritedAttributes(IPropertySymbol property) + { + ImmutableArray own = property.GetAttributes(); + if (property.OverriddenProperty is null) + { + return own; + } + + var seen = new HashSet(StringComparer.Ordinal); + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + AppendUnique(builder, seen, own); + for (IPropertySymbol? baseProperty = property.OverriddenProperty; baseProperty is not null; baseProperty = baseProperty.OverriddenProperty) + { + AppendUnique(builder, seen, baseProperty.GetAttributes()); + } + + return builder.ToImmutable(); + } + + private static void AppendUnique( + ImmutableArray.Builder builder, + HashSet seen, + ImmutableArray attributes) + { + foreach (AttributeData attribute in attributes) + { + if (attribute.AttributeClass is not { } attributeClass) + { + continue; + } + + string key = attributeClass.ToDisplayString(FullyQualifiedFormat); + if (seen.Add(key)) + { + builder.Add(attribute); + } + } + } private static EquatableArray BuildParameters(IMethodSymbol method) { diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 9363460a93..6d7daadf99 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -45,6 +45,12 @@ public class TestCategoryAttribute : System.Attribute [System.AttributeUsage(System.AttributeTargets.Property)] public class TestContextAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestInitializeAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestCleanupAttribute : System.Attribute { } } """; @@ -463,6 +469,301 @@ public void Test1() { } result.Results[0].GeneratedSources.Should().HaveCount(2); } + [TestMethod] + public void Generator_IncludesMethodsFromBaseType() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestInitialize] + public void Setup() { } + + [TestMethod] + public void InheritedTest() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void DerivedTest() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"InheritedTest\""); + registry.Should().Contain("Name = \"Setup\""); + registry.Should().Contain("Name = \"DerivedTest\""); + // The TestInitialize attribute applied on the base method must propagate too. + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute"); + } + + [TestMethod] + public void Generator_IncludesMethodsFromMultiLevelInheritance() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class GrandparentTests + { + [TestMethod] + public void GrandparentTest() { } + } + + public class ParentTests : GrandparentTests + { + [TestMethod] + public void ParentTest() { } + } + + [TestClass] + public class LeafTests : ParentTests + { + [TestMethod] + public void LeafTest() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"GrandparentTest\""); + registry.Should().Contain("Name = \"ParentTest\""); + registry.Should().Contain("Name = \"LeafTest\""); + } + + [TestMethod] + public void Generator_OverriddenVirtualMethod_KeepsOnlyDerivedImplementation() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + public virtual void Run() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public override void Run() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Only one entry for Run should be emitted, and the invoker must dispatch on the derived type. + int runEntries = registry.Split(["Name = \"Run\""], System.StringSplitOptions.None).Length - 1; + runEntries.Should().Be(1, "the derived override must replace the base entry (not duplicate it)"); + registry.Should().Contain("((global::Sample.DerivedTests)instance!).Run();"); + registry.Should().NotContain("((global::Sample.BaseTests)instance!).Run();"); + + // The override does NOT re-apply [TestMethod] but the attribute must still be visible + // via the override chain — matching the runtime semantics of GetCustomAttributes(inherit: true). + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"); + } + + [TestMethod] + public void Generator_NewKeywordHiddenMethod_DedupsBySignature() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + public void Hidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public new void Hidden() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + int hiddenEntries = registry.Split(["Name = \"Hidden\""], System.StringSplitOptions.None).Length - 1; + hiddenEntries.Should().Be(1, "members with the same name and signature must be de-duplicated; derived wins"); + registry.Should().Contain("((global::Sample.DerivedTests)instance!).Hidden();"); + } + + [TestMethod] + public void Generator_OverloadsWithDifferentSignatures_AreAllPreserved() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + public void Op(int x) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void Op(string x) { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Both overloads survive — they have different signatures. + int opEntries = registry.Split(["Name = \"Op\""], System.StringSplitOptions.None).Length - 1; + opEntries.Should().Be(2); + registry.Should().Contain("typeof(int)"); + registry.Should().Contain("typeof(string)"); + } + + [TestMethod] + public void Generator_IncludesPropertiesFromBaseType() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + public class BaseTests + { + [TestContext] + public virtual TestContext Context { get; set; } = new(); + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"Context\""); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContextAttribute"); + } + + [TestMethod] + public void Generator_DoesNotInheritConstructors() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + public BaseTests(int x) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public DerivedTests() : base(1) { } + + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Only the derived ctor (parameterless) should be emitted — base ctor is never inherited. + registry.Should().Contain("Invoke = static args => new global::Sample.DerivedTests(),"); + registry.Should().NotContain("Invoke = static args => new global::Sample.BaseTests("); + // No int parameter from the base constructor leaks into the constructor list. + registry.Should().NotContain("ParameterTypes = new Type[] { typeof(int) },"); + } + + [TestMethod] + public void Generator_AbstractBaseWithConcreteDerived_FoldsBaseMembers() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + public abstract class AbstractBase + { + [TestInitialize] + public void Setup() { } + + [TestContext] + public TestContext Ctx { get; set; } = new(); + } + + [TestClass] + public class Concrete : AbstractBase + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"Setup\""); + registry.Should().Contain("Name = \"Ctx\""); + registry.Should().Contain("Name = \"Test\""); + // The base class was abstract but the concrete derived type is the one emitted. + registry.Should().Contain("Type = typeof(global::Sample.Concrete)"); + } + + [TestMethod] + public void Generator_DoesNotWalkPastSystemObject() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class SimpleTests + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Members of System.Object (ToString, Equals, GetHashCode, GetType) must NOT be emitted. + registry.Should().NotContain("Name = \"ToString\""); + registry.Should().NotContain("Name = \"Equals\""); + registry.Should().NotContain("Name = \"GetHashCode\""); + registry.Should().NotContain("Name = \"GetType\""); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From e4d126ea8daf0c5078753dfc9b452abfc550c0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 15:55:19 +0200 Subject: [PATCH 04/10] AotReflection: capture assembly-level attributes Adds an AssemblyAttributes property to the emitted MSTestReflectionMetadata registry containing all attributes declared with [assembly: ...] in the same compilation. The attribute payload is built via the existing AttributeApplicationModel pipeline (reused from class/method attribute emission), so adapters can iterate without calling Assembly.GetCustomAttributes at runtime. Part of #1837. Stacked on #9004, #9005, #9006. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MSTestReflectionMetadataGenerator.cs | 19 ++- .../Generators/MetadataRegistryEmitter.cs | 37 +++++- .../Model/TestClassModel.cs | 8 ++ .../MSTestReflectionMetadataGeneratorTests.cs | 119 ++++++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs index b94f9bc9ca..c0baa4025b 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs @@ -45,14 +45,27 @@ node is TypeDeclarationSyntax type .Where(static model => model is not null) .Select(static (model, _) => model!); - IncrementalValueProvider<(string? AssemblyName, ImmutableArray Classes)> combined = + // Pull assembly-level attributes from the compilation (one value per run) and + // wrap them in an equatable model so this branch of the pipeline can stay cached + // when only test-class code changes. + IncrementalValueProvider assemblyMetadata = + context.CompilationProvider.Select(static (c, ct) => + { + ct.ThrowIfCancellationRequested(); + return new AssemblyMetadataModel( + TestClassModelBuilder.BuildAttributes(c.Assembly.GetAttributes())); + }); + + IncrementalValueProvider<(string? AssemblyName, AssemblyMetadataModel Metadata, ImmutableArray Classes)> combined = context.CompilationProvider.Select(static (c, _) => c.AssemblyName) - .Combine(testClasses.Collect()); + .Combine(assemblyMetadata) + .Combine(testClasses.Collect()) + .Select(static (tuple, _) => (tuple.Left.Left, tuple.Left.Right, tuple.Right)); context.RegisterImplementationSourceOutput(combined, static (ctx, payload) => { string assemblyName = payload.AssemblyName ?? "Unknown"; - string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Classes); + string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Metadata, payload.Classes); ctx.AddSource("MSTestReflectionMetadata.Registry.g.cs", SourceText.From(source, Encoding.UTF8)); }); } diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index 7582344e7d..e4779cf4f6 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -83,7 +83,7 @@ public static string EmitSupportTypes() return sb.ToString(); } - public static string EmitRegistry(string assemblyName, IReadOnlyList testClasses) + public static string EmitRegistry(string assemblyName, AssemblyMetadataModel assemblyMetadata, IReadOnlyList testClasses) { var sb = new IndentedStringBuilder(); AppendHeader(sb); @@ -99,6 +99,12 @@ public static string EmitRegistry(string assemblyName, IReadOnlyList TestClasses { get; } = new TestClassReflectionInfo[]"); using (sb.Block(null)) { @@ -119,6 +125,35 @@ public static string EmitRegistry(string assemblyName, IReadOnlyList attributes) + { + if (attributes.Length == 0) + { + sb.AppendLine("public static IReadOnlyList AssemblyAttributes { get; } = Array.Empty();"); + return; + } + + sb.AppendLine("public static IReadOnlyList AssemblyAttributes { get; } = new Attribute[]"); + using (sb.Block(null)) + { + for (int i = 0; i < attributes.Length; i++) + { + AttributeApplicationModel attr = attributes[i]; + sb.Append(BuildAttributeExpression(attr)); + if (i < attributes.Length - 1) + { + sb.AppendLine(","); + } + else + { + sb.AppendLine(); + } + } + } + + sb.AppendLine(";"); + } + private static void EmitTestClass(IndentedStringBuilder sb, TestClassModel model) { string fqn = model.FullyQualifiedTypeName; diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs index cc7586fdef..db2818dfce 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs @@ -60,6 +60,14 @@ internal sealed record TestPropertyModel( internal sealed record TestConstructorModel( EquatableArray Parameters); +/// +/// Assembly-scoped metadata captured at compile time so the consumer never has to call +/// for +/// attributes declared with [assembly: ...] in the same compilation. +/// +internal sealed record AssemblyMetadataModel( + EquatableArray Attributes); + internal sealed record TestClassModel( string FullyQualifiedTypeName, string ContainingNamespace, diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 6d7daadf99..04a13d6624 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -51,6 +51,13 @@ public class TestInitializeAttribute : System.Attribute { } [System.AttributeUsage(System.AttributeTargets.Method)] public class TestCleanupAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] + public class ParallelizeAttribute : System.Attribute + { + public int Workers { get; set; } + public string? Scope { get; set; } + } } """; @@ -764,6 +771,118 @@ public void Test() { } registry.Should().NotContain("Name = \"GetType\""); } + [TestMethod] + public void Generator_CapturesAssemblyLevelAttribute() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [assembly: Parallelize(Workers = 4, Scope = "Method")] + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList AssemblyAttributes { get; } = new Attribute[]"); + registry.Should().Contain("new global::Microsoft.VisualStudio.TestTools.UnitTesting.ParallelizeAttribute()"); + registry.Should().Contain("Workers = 4"); + registry.Should().Contain("Scope = \"Method\""); + } + + [TestMethod] + public void Generator_AssemblyAttributes_IsEmptyArray_WhenNoneApplied() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList AssemblyAttributes { get; } = Array.Empty();"); + registry.Should().NotContain("public static IReadOnlyList AssemblyAttributes { get; } = new Attribute[]"); + } + + [TestMethod] + public void Generator_CapturesMultipleAssemblyAttributes_InDeclarationOrder() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [assembly: Parallelize(Workers = 1)] + [assembly: Parallelize(Workers = 2)] + [assembly: Parallelize(Workers = 3)] + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + + int idx1 = registry.IndexOf("Workers = 1", StringComparison.Ordinal); + int idx2 = registry.IndexOf("Workers = 2", StringComparison.Ordinal); + int idx3 = registry.IndexOf("Workers = 3", StringComparison.Ordinal); + + idx1.Should().BeGreaterThan(-1); + idx2.Should().BeGreaterThan(idx1); + idx3.Should().BeGreaterThan(idx2); + } + + [TestMethod] + public void Generator_AssemblyAttributes_AreEmittedEvenWithNoTestClasses() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [assembly: Parallelize(Workers = 8)] + + namespace Sample + { + public class NotATest { } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("new global::Microsoft.VisualStudio.TestTools.UnitTesting.ParallelizeAttribute()"); + registry.Should().Contain("Workers = 8"); + registry.Should().NotContain("new TestClassReflectionInfo("); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 517e185c957c9ec0d9f630befd6748638451b81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 16:40:44 +0200 Subject: [PATCH 05/10] A4: Materialize [DataRow] arguments into TestMethodReflectionInfo Part of #1837. Adds compile-time materialization of `[DataRow]` attribute applications on `[TestMethod]` members. The generator now emits a `DataRows` property on each `TestMethodReflectionInfo` containing a flat `IReadOnlyList` mirroring the runtime shape of `DataRowAttribute.Data`, so a consumer can iterate parameterised cases without re-reading the attributes via reflection. Highlights: - New `DataRowModel(EquatableArray Arguments)` capturing one row of arguments per attribute application. - `BuildDataRows` detects `Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute` applications and flattens the variadic `params object?[] moreData` tail back into the row so the emitted array matches `DataRowAttribute.Data` rather than preserving a nested array. - Inheritance-aware: reuses the inherited attribute walk introduced in #9006, so `[DataRow]` applied on a base method (when the override is virtual) is still picked up. - Emitter always emits `DataRows` (empty array for non-data-driven tests) for shape parity with the other `TestMethodReflectionInfo` properties. Deferred to a follow-up: `[DynamicData]` materialization. Resolving the data source method/property/field at compile time is materially more complex (handles `Method`/`Property`/`Field`/`AutoDetect` source kinds plus `object[]` / `IEnumerable` return shapes) and warrants its own PR. Tests: - `Generator_EmitsEmptyDataRows_WhenMethodHasNoDataRow` - `Generator_CapturesSingleDataRow_WithScalarArgs` - `Generator_CapturesMultipleDataRows_InDeclarationOrder` - `Generator_FlattensParamsArrayInDataRow` - `Generator_HandlesNullValueInDataRow` Total: 32/32 passing. Depends on #9004, #9005, #9006, #9007. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/MetadataRegistryEmitter.cs | 42 +++++ .../Generators/TestClassModelBuilder.cs | 61 ++++++- .../Model/TestClassModel.cs | 9 +- .../MSTestReflectionMetadataGeneratorTests.cs | 149 ++++++++++++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index e4779cf4f6..50ad1b1703 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -57,6 +57,8 @@ public static string EmitSupportTypes() sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty();"); sb.AppendLine("public string[] ParameterNames { get; init; } = Array.Empty();"); sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); + sb.AppendLine("/// Materialized argument tuples from [DataRow] attributes (empty for non-data-driven tests). Each object?[] corresponds to one [DataRow] application."); + sb.AppendLine("public IReadOnlyList DataRows { get; init; } = Array.Empty();"); sb.AppendLine("/// Direct invoker — replaces ."); sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => null;"); } @@ -225,6 +227,8 @@ private static void EmitMethods(IndentedStringBuilder sb, string fqn, TestClassM sb.AppendLine(","); EmitAttributesProperty(sb, "Attributes", method.Attributes); sb.AppendLine(","); + EmitDataRows(sb, method.DataRows); + sb.AppendLine(","); EmitMethodInvoker(sb, fqn, method); } @@ -280,6 +284,44 @@ private static void EmitMethodInvoker(IndentedStringBuilder sb, string classFqn, sb.AppendLine($"Invoke = static (instance, args) => {body},"); } + private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray dataRows) + { + if (dataRows.Length == 0) + { + sb.Append("DataRows = Array.Empty()"); + sb.AppendLine(); + return; + } + + sb.AppendLine("DataRows = new object?[][]"); + using (sb.Block(null)) + { + for (int i = 0; i < dataRows.Length; i++) + { + EquatableArray args = dataRows[i].Arguments; + if (args.Length == 0) + { + sb.Append("Array.Empty()"); + } + else + { + string literals = string.Join(", ", args.AsImmutableArray().Select(BuildConstantExpression)); + sb.Append($"new object?[] {{ {literals} }}"); + } + + if (i < dataRows.Length - 1) + { + sb.AppendLine(","); + } + else + { + sb.AppendLine(); + } + } + } + } + + private static void EmitParameterTypes(IndentedStringBuilder sb, EquatableArray parameters) { if (parameters.Length == 0) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 2881029581..c5029b4100 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis; +using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; namespace MSTest.AotReflection.SourceGeneration.Generators; @@ -149,6 +150,8 @@ private static TestMethodModel BuildMethod(IMethodSymbol method) || returnTypeFqn.StartsWith("global::System.Threading.Tasks.ValueTask<", System.StringComparison.Ordinal); bool returnsVoid = returnType.SpecialType == SpecialType.System_Void; + ImmutableArray inheritedAttributes = CollectInheritedAttributes(method); + return new TestMethodModel( Name: method.Name, IsStatic: method.IsStatic, @@ -157,7 +160,63 @@ private static TestMethodModel BuildMethod(IMethodSymbol method) ReturnsValueTask: returnsValueTask, ReturnsVoid: returnsVoid, Parameters: BuildParameters(method), - Attributes: BuildAttributes(CollectInheritedAttributes(method))); + Attributes: BuildAttributes(inheritedAttributes), + DataRows: BuildDataRows(inheritedAttributes)); + } + + // Walks the attribute list and reifies each [DataRow(...)] application into a flat + // object?[] row. Mirrors DataRowAttribute's runtime behavior: when the constructor uses + // the variadic overload (object? data1, params object?[] moreData), Roslyn surfaces the + // tail as a single Array TypedConstant, which we flatten back so the consumer sees the + // same shape as DataRowAttribute.Data. + private static EquatableArray BuildDataRows(ImmutableArray attributes) + { + if (attributes.IsDefaultOrEmpty) + { + return EquatableArray.Empty; + } + + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + foreach (AttributeData attribute in attributes) + { + if (attribute.AttributeClass is not { } attributeClass) + { + continue; + } + + if (attributeClass.ToDisplayString(FullyQualifiedFormat) != "global::" + MSTestAttributeNames.DataRow) + { + continue; + } + + ImmutableArray ctorArgs = attribute.ConstructorArguments; + ImmutableArray.Builder rowBuilder = ImmutableArray.CreateBuilder(); + + bool lastIsParamsArray = + attribute.AttributeConstructor is { Parameters: { IsDefaultOrEmpty: false } parameters } + && parameters[parameters.Length - 1].IsParams + && !ctorArgs.IsDefaultOrEmpty + && ctorArgs[ctorArgs.Length - 1].Kind == TypedConstantKind.Array; + + for (int i = 0; i < ctorArgs.Length; i++) + { + if (i == ctorArgs.Length - 1 && lastIsParamsArray) + { + foreach (TypedConstant element in ctorArgs[i].Values) + { + rowBuilder.Add(ToModel(element)); + } + } + else + { + rowBuilder.Add(ToModel(ctorArgs[i])); + } + } + + builder.Add(new DataRowModel(new EquatableArray(rowBuilder.ToImmutable()))); + } + + return new EquatableArray(builder.ToImmutable()); } private static TestPropertyModel BuildProperty(IPropertySymbol property) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs index db2818dfce..0be713a783 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs @@ -41,6 +41,12 @@ internal enum ConstantValueKind internal sealed record TestParameterModel(string FullyQualifiedType, string Name); +/// +/// One row of arguments from a [DataRow] attribute, materialized at compile time so +/// the consumer can iterate without re-reading DataRowAttribute.Data via reflection. +/// +internal sealed record DataRowModel(EquatableArray Arguments); + internal sealed record TestMethodModel( string Name, bool IsStatic, @@ -49,7 +55,8 @@ internal sealed record TestMethodModel( bool ReturnsValueTask, bool ReturnsVoid, EquatableArray Parameters, - EquatableArray Attributes); + EquatableArray Attributes, + EquatableArray DataRows); internal sealed record TestPropertyModel( string Name, diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 04a13d6624..5586f6c6dc 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -58,6 +58,13 @@ public class ParallelizeAttribute : System.Attribute public int Workers { get; set; } public string? Scope { get; set; } } + + [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)] + public class DataRowAttribute : System.Attribute + { + public DataRowAttribute(object? data1) { } + public DataRowAttribute(object? data1, params object?[] moreData) { } + } } """; @@ -883,6 +890,148 @@ public class NotATest { } registry.Should().NotContain("new TestClassReflectionInfo("); } + [TestMethod] + public void Generator_EmitsEmptyDataRows_WhenMethodHasNoDataRow() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void NoData() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = Array.Empty()"); + registry.Should().NotContain("DataRows = new object?[][]"); + } + + [TestMethod] + public void Generator_CapturesSingleDataRow_WithScalarArgs() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(1, "x")] + public void Test(int a, string b) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = new object?[][]"); + registry.Should().Contain("new object?[] { 1, \"x\" }"); + } + + [TestMethod] + public void Generator_CapturesMultipleDataRows_InDeclarationOrder() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(1, "a")] + [DataRow(2, "b")] + [DataRow(3, "c")] + public void Test(int a, string b) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = new object?[][]"); + + int idx1 = registry.IndexOf("new object?[] { 1, \"a\" }", StringComparison.Ordinal); + int idx2 = registry.IndexOf("new object?[] { 2, \"b\" }", StringComparison.Ordinal); + int idx3 = registry.IndexOf("new object?[] { 3, \"c\" }", StringComparison.Ordinal); + + idx1.Should().BeGreaterThan(-1); + idx2.Should().BeGreaterThan(idx1); + idx3.Should().BeGreaterThan(idx2); + } + + [TestMethod] + public void Generator_FlattensParamsArrayInDataRow() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(1, 2, 3, 4)] + public void Test(int a, int b, int c, int d) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // The variadic `params object?[] moreData` tail must be flattened into a single flat row + // within the DataRows block — the row contains all four values inline, not nested. + registry.Should().Contain("new object?[] { 1, 2, 3, 4 }"); + } + + [TestMethod] + public void Generator_HandlesNullValueInDataRow() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(null)] + public void Test(string? value) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = new object?[][]"); + // The single-arg DataRowAttribute(object? data1) overload binds null to object, + // which surfaces as `(object)null!` from BuildConstantExpression (C# keyword form + // produced by FullyQualifiedFormat for System.Object). + registry.Should().Contain("new object?[] { (object)null! }"); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 1d894a444211b5d7ff5644d191de39ab0e7c48e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 17:01:45 +0200 Subject: [PATCH 06/10] A5: Make TestMethodReflectionInfo.Invoke async-aware (Task-returning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #1837. Changes the emitted `TestMethodReflectionInfo.Invoke` delegate from `Func` to `Func` so the caller can `await` a single Task regardless of the underlying test method's signature. Per return shape: - `void` / non-Task sync: `{ call; return Task.CompletedTask; }` — no allocation, no extra wrapping. - `Task` / `Task`: `{ Task? __t = call; return __t ?? Task.CompletedTask; }` — forward the Task; tolerate a misbehaving test returning `null` rather than NRE. - `ValueTask` / `ValueTask`: `{ var __vt = call; return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); }` — fast path for synchronous completion skips `AsTask()` allocation. - Non-void non-Task sync (e.g. `int Test()`): `{ _ = call; return Task.CompletedTask; }` — value discarded, side effects retained. Support type updated to declare `Invoke` as `Func` with default `static (_, _) => Task.CompletedTask`. Both the support-types file and the registry file now import `System.Threading.Tasks`. Also drops a duplicate blank line left over from PR-A4 (SA1507). Tests: - `Generator_SupportType_DeclaresInvokeAsTaskReturning` - `Generator_InvokerForVoidMethod_ReturnsCompletedTask` - `Generator_InvokerForTaskMethod_ForwardsTask` - `Generator_InvokerForTaskOfTMethod_ForwardsTask` - `Generator_InvokerForValueTaskMethod_UnwrapsViaAsTask` - `Generator_InvokerForValueTaskOfTMethod_UnwrapsViaAsTask` - `Generator_InvokerForNonVoidSyncMethod_DiscardsResultAndReturnsCompletedTask` - `Generator_EmittedRegistry_ImportsSystemThreadingTasks` Total: 40/40 passing (1 existing test updated for new shape). Depends on #9004, #9005, #9006, #9007, #9011. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/MetadataRegistryEmitter.cs | 39 +++- .../MSTestReflectionMetadataGeneratorTests.cs | 187 +++++++++++++++++- 2 files changed, 219 insertions(+), 7 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index 50ad1b1703..efead40f59 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -32,6 +32,7 @@ public static string EmitSupportTypes() sb.AppendLine("using System;"); sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine(); using (sb.Block($"namespace {GeneratedNamespace}")) @@ -59,8 +60,8 @@ public static string EmitSupportTypes() sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); sb.AppendLine("/// Materialized argument tuples from [DataRow] attributes (empty for non-data-driven tests). Each object?[] corresponds to one [DataRow] application."); sb.AppendLine("public IReadOnlyList DataRows { get; init; } = Array.Empty();"); - sb.AppendLine("/// Direct invoker — replaces ."); - sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => null;"); + sb.AppendLine("/// Direct invoker — replaces . Always returns a non-null so the caller can await regardless of whether the underlying test method is void, Task, Task<T>, ValueTask, or ValueTask<T>; the result value (if any) is discarded."); + sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;"); } sb.AppendLine(); @@ -92,6 +93,7 @@ public static string EmitRegistry(string assemblyName, AssemblyMetadataModel ass sb.AppendLine("using System;"); sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine(); using (sb.Block($"namespace {GeneratedNamespace}")) @@ -277,9 +279,35 @@ private static void EmitMethodInvoker(IndentedStringBuilder sb, string classFqn, string args = BuildArgumentsFromObjectArray(method.Parameters); string call = $"{target}.{method.Name}({args})"; - string body = method.ReturnsVoid - ? $"{{ {call}; return null; }}" - : $"{{ return {call}; }}"; + // The contract is: return a non-null Task representing the (async or sync) completion of the + // test method, discarding any result value. This lets the caller use a single `await invoker(...)` + // path regardless of the underlying return shape. + // - void / non-Task sync: invoke, return Task.CompletedTask. + // - Task / Task: forward the returned Task (treat a `null` return as success). + // - ValueTask / ValueTask: avoid Task allocation for the synchronously-completed fast path + // via IsCompletedSuccessfully, otherwise call AsTask(). + string body; + if (method.ReturnsTask) + { + // Task derives from Task, so the same forwarding code handles both. A test method that + // *declares* a Task return type and then returns `null` is broken at runtime, but mirroring + // reflection-Invoke we tolerate it and treat it as already-completed. + body = $"{{ Task? __t = {call}; return __t ?? Task.CompletedTask; }}"; + } + else if (method.ReturnsValueTask) + { + body = $"{{ var __vt = {call}; return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); }}"; + } + else if (method.ReturnsVoid) + { + body = $"{{ {call}; return Task.CompletedTask; }}"; + } + else + { + // Non-void, non-Task return (e.g. `int Test()`). The test runner discards the value; we still + // execute the call for its side effects and report success. + body = $"{{ _ = {call}; return Task.CompletedTask; }}"; + } sb.AppendLine($"Invoke = static (instance, args) => {body},"); } @@ -321,7 +349,6 @@ private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray parameters) { if (parameters.Length == 0) diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 5586f6c6dc..ff8fe422c1 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -116,7 +116,7 @@ public void Test1() { } registry.Should().Contain("public const string AssemblyName = \"TestSample\";"); registry.Should().Contain("Type = typeof(global::Sample.MyTests)"); registry.Should().Contain("Name = \"Test1\""); - registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return null; },"); + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return Task.CompletedTask; },"); } [TestMethod] @@ -1032,6 +1032,191 @@ public void Test(string? value) { } registry.Should().Contain("new object?[] { (object)null! }"); } + [TestMethod] + public void Generator_SupportType_DeclaresInvokeAsTaskReturning() + { + const string userCode = """ + // Empty consumer — we only care about the post-init support types. + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + support.Should().Contain("using System.Threading.Tasks;"); + // Invoke must be Task-returning so consumers can await without type-testing the result. + support.Should().Contain("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;"); + } + + [TestMethod] + public void Generator_InvokerForVoidMethod_ReturnsCompletedTask() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void SyncVoid() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.Tests)instance!).SyncVoid(); return Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_InvokerForTaskMethod_ForwardsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public Task AsyncTask() => Task.CompletedTask; + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Task and Task both forward via the same `Task? __t = …` path; null is tolerated so + // the invoker contract (non-null Task) holds even for a misbehaving test method. + registry.Should().Contain("Invoke = static (instance, args) => { Task? __t = ((global::Sample.Tests)instance!).AsyncTask(); return __t ?? Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_InvokerForTaskOfTMethod_ForwardsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public Task AsyncTaskOfInt() => Task.FromResult(42); + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Invoke = static (instance, args) => { Task? __t = ((global::Sample.Tests)instance!).AsyncTaskOfInt(); return __t ?? Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_InvokerForValueTaskMethod_UnwrapsViaAsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public ValueTask AsyncValueTask() => default; + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // ValueTask unwrap uses IsCompletedSuccessfully so the synchronous-completion fast path + // skips the Task allocation; only when the operation actually went async do we pay AsTask(). + registry.Should().Contain("Invoke = static (instance, args) => { var __vt = ((global::Sample.Tests)instance!).AsyncValueTask(); return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); },"); + } + + [TestMethod] + public void Generator_InvokerForValueTaskOfTMethod_UnwrapsViaAsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public ValueTask AsyncValueTaskOfString() => new ValueTask("ok"); + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Invoke = static (instance, args) => { var __vt = ((global::Sample.Tests)instance!).AsyncValueTaskOfString(); return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); },"); + } + + [TestMethod] + public void Generator_InvokerForNonVoidSyncMethod_DiscardsResultAndReturnsCompletedTask() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public int SyncInt() => 42; + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // For a sync non-void test the returned value is discarded but the call must still execute + // (its side-effects ARE the test). We surface that with a `_ = call;` pattern. + registry.Should().Contain("Invoke = static (instance, args) => { _ = ((global::Sample.Tests)instance!).SyncInt(); return Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_EmittedRegistry_ImportsSystemThreadingTasks() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // The registry file references Task.CompletedTask directly in every invoker, so it must + // bring System.Threading.Tasks into scope. + registry.Should().Contain("using System.Threading.Tasks;"); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 8b9414508cd314e70e93bbb1169dc1ccc588986d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 17:38:39 +0200 Subject: [PATCH 07/10] Add analyzer diagnostics (AOTSG0001-AOTSG0005) for unsupported test-class shapes Adds analyzer-style diagnostics emitted by MSTest.AotReflection.SourceGeneration when it encounters a [TestClass] shape it cannot lower into the generated registry: - AOTSG0001 - static [TestClass] cannot be instantiated. - AOTSG0002 - open-generic [TestClass] (directly or via outer generic). - AOTSG0003 - inaccessible [TestClass] (file-local, private/protected nested, or nested in an inaccessible outer). - AOTSG0004 - generic [TestMethod] cannot be invoked without runtime instantiation. - AOTSG0005 - [TestMethod]/constructor parameter uses ef/in/out. When any of these fire, the offending type or member is skipped from the emitted registry instead of producing invalid C#. Adds 10 new tests covering positive and negative cases, plus AnalyzerReleases.{Shipped,Unshipped}.md to satisfy RS2008. Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 12 + .../Diagnostics/DiagnosticDescriptors.cs | 78 +++++ .../Diagnostics/DiagnosticInfo.cs | 38 +++ .../Diagnostics/LocationInfo.cs | 46 +++ .../MSTestReflectionMetadataGenerator.cs | 126 +++++++- .../Generators/TestClassModelBuilder.cs | 54 +++- ...Test.AotReflection.SourceGeneration.csproj | 5 + .../MSTestReflectionMetadataGeneratorTests.cs | 284 +++++++++++++++++- 9 files changed, 626 insertions(+), 19 deletions(-) create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..f50bb1fe21 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000000..72fe00128f --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md @@ -0,0 +1,12 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +AOTSG0001 | MSTest.AotReflection | Warning | StaticTestClass +AOTSG0002 | MSTest.AotReflection | Warning | GenericTestClass +AOTSG0003 | MSTest.AotReflection | Warning | InaccessibleTestClass +AOTSG0004 | MSTest.AotReflection | Warning | GenericTestMethod +AOTSG0005 | MSTest.AotReflection | Warning | ByRefParameter diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 0000000000..52c32f5127 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,78 @@ +// 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; +using System.Collections.Generic; + +using Microsoft.CodeAnalysis; + +namespace MSTest.AotReflection.SourceGeneration.Diagnostics; + +/// +/// Catalogue of values surfaced by the AOT reflection +/// source generator when it encounters a [TestClass] shape it cannot materialize. +/// Each id is registered in AnalyzerReleases.Unshipped.md. +/// +internal static class DiagnosticDescriptors +{ + private const string Category = "MSTest.AotReflection"; + + public static readonly DiagnosticDescriptor StaticTestClass = new( + id: "AOTSG0001", + title: "Test class is static", + messageFormat: "[TestClass] type '{0}' is static and cannot be instantiated by the generated registry", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Static test classes cannot be instantiated by the generated registry. Remove the 'static' modifier or use a non-static container."); + + public static readonly DiagnosticDescriptor GenericTestClass = new( + id: "AOTSG0002", + title: "Test class is generic", + messageFormat: "[TestClass] type '{0}' has unbound type parameters (either directly or via a generic outer type) and cannot be materialized as a closed type", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Open generic test classes (or test classes nested inside a generic outer type) have no closed type at generation time. Use a concrete subclass that closes every type parameter."); + + public static readonly DiagnosticDescriptor InaccessibleTestClass = new( + id: "AOTSG0003", + title: "Test class is not accessible from generated code", + messageFormat: "[TestClass] type '{0}' is not reachable from generated code in the same assembly (file-local, or nested in a private/private-protected outer type)", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Generated registry code lives in the same assembly but in a different file/type and therefore cannot reference file-local types or types nested in a private (or private-protected) outer type. Make the test class — and every enclosing type — at least internal."); + + public static readonly DiagnosticDescriptor GenericTestMethod = new( + id: "AOTSG0004", + title: "Test method is generic", + messageFormat: "Method '{0}.{1}' has type parameters which are not knowable at compile time; the source-generated invoker will skip it", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Generic test methods would need a concrete type-argument list at the invocation site. Replace the method with one or more non-generic specializations."); + + public static readonly DiagnosticDescriptor ByRefParameter = new( + id: "AOTSG0005", + title: "Parameter uses a by-ref kind", + messageFormat: "Parameter '{2}' of '{0}.{1}' is declared with 'ref', 'in', or 'out' and cannot be passed through the 'object?[]' invoker; the member will be skipped", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "By-ref parameters cannot flow through the 'Func' invoker shape. Drop the ref/in/out modifier or move the dependency out of the test signature."); + + private static readonly Dictionary ById = new(StringComparer.Ordinal) + { + [StaticTestClass.Id] = StaticTestClass, + [GenericTestClass.Id] = GenericTestClass, + [InaccessibleTestClass.Id] = InaccessibleTestClass, + [GenericTestMethod.Id] = GenericTestMethod, + [ByRefParameter.Id] = ByRefParameter, + }; + + public static DiagnosticDescriptor GetById(string id) + => ById.TryGetValue(id, out DiagnosticDescriptor? descriptor) + ? descriptor + : throw new InvalidOperationException($"Unknown diagnostic id '{id}'."); +} diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs new file mode 100644 index 0000000000..06eda08594 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs @@ -0,0 +1,38 @@ +// 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 Microsoft.CodeAnalysis; + +using MSTest.AotReflection.SourceGeneration.Model; + +namespace MSTest.AotReflection.SourceGeneration.Diagnostics; + +/// +/// Equatable payload that travels through the incremental-generator pipeline and is +/// reified into a real only at the RegisterSourceOutput +/// stage. Holding only the descriptor id (rather than the descriptor itself) keeps the +/// record cheaply equatable across runs. +/// +internal sealed record DiagnosticInfo( + string DescriptorId, + LocationInfo? Location, + EquatableArray MessageArgs) +{ + public Diagnostic ToDiagnostic() + { + DiagnosticDescriptor descriptor = DiagnosticDescriptors.GetById(DescriptorId); + ImmutableArray args = MessageArgs.AsImmutableArray(); + object?[] formattedArgs = new object?[args.Length]; + for (int i = 0; i < args.Length; i++) + { + formattedArgs[i] = args[i]; + } + + return Diagnostic.Create(descriptor, Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None, formattedArgs); + } + + public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, LocationInfo? location, params string[] messageArgs) + => new(descriptor.Id, location, new EquatableArray(ImmutableArray.Create(messageArgs))); +} diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs new file mode 100644 index 0000000000..b521ab17fd --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace MSTest.AotReflection.SourceGeneration.Diagnostics; + +/// +/// Value-equatable surrogate for so a reported +/// can flow through incremental-generator pipelines +/// without breaking the model-equality contract that gates step caching. +/// +internal sealed record LocationInfo(string FilePath, TextSpan SourceSpan, LinePositionSpan LineSpan) +{ + public Location ToLocation() + => Location.Create(FilePath, SourceSpan, LineSpan); + + public static LocationInfo? CreateFrom(SyntaxNode node) + => CreateFrom(node.GetLocation()); + + public static LocationInfo? CreateFrom(ISymbol symbol) + { + foreach (SyntaxReference reference in symbol.DeclaringSyntaxReferences) + { + // The first declaration is the canonical one; partial declarations get the first + // physical occurrence which is good enough for diagnostic placement. + return CreateFrom(reference.GetSyntax().GetLocation()); + } + + return null; + } + + public static LocationInfo? CreateFrom(Location location) + { + if (location.SourceTree is null) + { + return null; + } + + return new LocationInfo( + location.SourceTree.FilePath, + location.SourceSpan, + location.GetLineSpan().Span); + } +} diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs index c0baa4025b..4267dbe718 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs @@ -1,6 +1,7 @@ // 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.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; @@ -10,6 +11,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using MSTest.AotReflection.SourceGeneration.Diagnostics; using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; @@ -35,15 +37,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "MSTestReflectionMetadata.SupportTypes.g.cs", SourceText.From(MetadataRegistryEmitter.EmitSupportTypes(), Encoding.UTF8))); - IncrementalValuesProvider testClasses = context.SyntaxProvider + IncrementalValuesProvider rawResults = context.SyntaxProvider .ForAttributeWithMetadataName( MSTestAttributeNames.TestClass, - predicate: static (node, _) => - node is TypeDeclarationSyntax type - && !type.Modifiers.Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)), - transform: static (ctx, ct) => BuildModel(ctx, ct)) - .Where(static model => model is not null) - .Select(static (model, _) => model!); + // Predicate stays cheap and shape-only. Diagnostics for unsupported shapes + // (static, generic, inaccessible, generic method, by-ref parameter) are + // computed in BuildModel where we have the full ISymbol. + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, ct) => BuildResult(ctx, ct)); + + // Surface every collected DiagnosticInfo as a real Diagnostic. Empty arrays produce + // no work, so this branch is allocation-free for clean compilations. + IncrementalValuesProvider diagnostics = rawResults + .SelectMany(static (result, _) => result.Diagnostics.AsImmutableArray()); + + context.RegisterSourceOutput(diagnostics, static (ctx, info) => + ctx.ReportDiagnostic(info.ToDiagnostic())); + + IncrementalValuesProvider testClasses = rawResults + .Where(static result => result.Model is not null) + .Select(static (result, _) => result.Model!); // Pull assembly-level attributes from the compilation (one value per run) and // wrap them in an equatable model so this branch of the pipeline can stay cached @@ -70,21 +83,108 @@ node is TypeDeclarationSyntax type }); } - private static TestClassModel? BuildModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + private static TestClassTransformResult BuildResult(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) { - return null; + return TestClassTransformResult.Empty; + } + + var diagnostics = new List(); + LocationInfo? classLocation = LocationInfo.CreateFrom(context.TargetNode); + + // Diagnostics that imply we cannot emit ANY model for this class. Reported in + // priority order — only the first matching reason is recorded so users aren't + // spammed with overlapping warnings (e.g. a static class is also "abstract" at the + // IL level, but AOTSG0001 is the only one that's actionable). + string fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if (typeSymbol.IsStatic) + { + diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.StaticTestClass, classLocation, fqn)); + return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics)); } - // Skip abstract / static / generic classes for this PoC — they need extra wiring. - if (typeSymbol.IsAbstract || typeSymbol.IsStatic || typeSymbol.IsGenericType) + if (IsGenericOrInsideGeneric(typeSymbol)) { - return null; + diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.GenericTestClass, classLocation, fqn)); + return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics)); } - return TestClassModelBuilder.Build(typeSymbol); + if (!IsReachableFromGeneratedCode(typeSymbol)) + { + diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.InaccessibleTestClass, classLocation, fqn)); + return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics)); + } + + // Abstract test classes stay silently filtered for now — they're a legitimate + // base-class pattern and the right UX needs the concrete-derived discovery from a + // future PR. + if (typeSymbol.IsAbstract) + { + return TestClassTransformResult.Empty; + } + + TestClassModel model = TestClassModelBuilder.Build(typeSymbol, diagnostics); + return new TestClassTransformResult(model, ToEquatable(diagnostics)); + } + + private static EquatableArray ToEquatable(List diagnostics) + => diagnostics.Count == 0 + ? EquatableArray.Empty + : new EquatableArray(diagnostics.ToImmutableArray()); + + private static bool IsGenericOrInsideGeneric(INamedTypeSymbol type) + { + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + if (current.IsGenericType) + { + return true; + } + } + + return false; + } + + private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) + { + // The generated registry lives in the same assembly but in a different file/type, + // so it can reach Public / Internal / ProtectedOrInternal types (the latter being + // "protected internal" — visible from anywhere in the same assembly). Private, + // Protected (alone), and ProtectedAndInternal ("private protected") containing + // types make the type unreachable. + if (type.IsFileLocal) + { + return false; + } + + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + if (current.IsFileLocal) + { + return false; + } + + switch (current.DeclaredAccessibility) + { + case Accessibility.Public: + case Accessibility.Internal: + case Accessibility.ProtectedOrInternal: + case Accessibility.NotApplicable: + continue; + default: + return false; + } + } + + return true; + } + + private sealed record TestClassTransformResult(TestClassModel? Model, EquatableArray Diagnostics) + { + public static readonly TestClassTransformResult Empty = new(null, EquatableArray.Empty); } } diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index c5029b4100..5797846dac 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis; +using MSTest.AotReflection.SourceGeneration.Diagnostics; using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; @@ -24,7 +25,7 @@ internal static class TestClassModelBuilder SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - public static TestClassModel Build(INamedTypeSymbol typeSymbol) + public static TestClassModel Build(INamedTypeSymbol typeSymbol, List diagnostics) { // Methods / properties are walked across the full inheritance chain (excluding // System.Object) so that MSTest members declared on a base class — @@ -41,6 +42,8 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol) ImmutableArray.Builder properties = ImmutableArray.CreateBuilder(); ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder(); + string leafFqn = typeSymbol.ToDisplayString(FullyQualifiedFormat); + for (INamedTypeSymbol? current = typeSymbol; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) @@ -53,6 +56,13 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol) { case IMethodSymbol { MethodKind: MethodKind.Ordinary } method when IsAccessibleFromConsumer(method): + if (TryReportUnsupportedMethod(method, leafFqn, diagnostics)) + { + // Skip generic / by-ref methods entirely so the emitter does not produce + // code that references unbound type parameters or ref/in/out arguments. + break; + } + string key = BuildMethodSignatureKey(method); if (!methodsByKey.ContainsKey(key)) { @@ -74,6 +84,11 @@ when IsAccessibleFromConsumer(property): break; case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor when isLeaf && ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: + if (TryReportUnsupportedMethod(ctor, leafFqn, diagnostics)) + { + break; + } + ctors.Add(new TestConstructorModel(BuildParameters(ctor))); break; } @@ -81,7 +96,7 @@ when IsAccessibleFromConsumer(property): } return new TestClassModel( - FullyQualifiedTypeName: typeSymbol.ToDisplayString(FullyQualifiedFormat), + FullyQualifiedTypeName: leafFqn, ContainingNamespace: typeSymbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : typeSymbol.ContainingNamespace.ToDisplayString(), @@ -94,6 +109,41 @@ when IsAccessibleFromConsumer(property): Attributes: BuildAttributes(typeSymbol.GetAttributes())); } + // Reports AOTSG0004 (generic method) and AOTSG0005 (by-ref parameter) when applicable. + // Returns true if the member must be excluded from the emitted model. + private static bool TryReportUnsupportedMethod(IMethodSymbol method, string owningClassFqn, List diagnostics) + { + bool unsupported = false; + + // AOTSG0004 only applies to ordinary methods. Constructors cannot be generic so + // method.IsGenericMethod is false for them. + if (method.IsGenericMethod) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.GenericTestMethod, + LocationInfo.CreateFrom(method), + owningClassFqn, + method.Name)); + unsupported = true; + } + + foreach (IParameterSymbol parameter in method.Parameters) + { + if (parameter.RefKind != RefKind.None) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.ByRefParameter, + LocationInfo.CreateFrom(parameter), + owningClassFqn, + method.MethodKind == MethodKind.Constructor ? ".ctor" : method.Name, + parameter.Name)); + unsupported = true; + } + } + + return unsupported; + } + private static bool IsAccessibleFromConsumer(ISymbol symbol) => symbol.DeclaredAccessibility is Accessibility.Public diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj index dc9bbade05..e988b20e1f 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj @@ -29,6 +29,11 @@ + + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index ff8fe422c1..1de711687f 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -158,9 +158,9 @@ public static void Test1() { } GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); - result.Diagnostics.Should().BeEmpty(); + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0001"); string registry = GetRegistry(result); - // Static classes are excluded by the predicate in the generator (cannot be instantiated). + // Static classes are excluded from the registry (cannot be instantiated) and reported via AOTSG0001. registry.Should().NotContain("StaticTests"); } @@ -216,9 +216,9 @@ public void Test1() { } GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); - result.Diagnostics.Should().BeEmpty(); + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0002"); string registry = GetRegistry(result); - // Open-generic test classes are out of scope for this PoC. + // Open-generic test classes are out of scope for this PoC and reported via AOTSG0002. registry.Should().NotContain("GenericTests"); } @@ -1217,6 +1217,282 @@ public void Test() { } registry.Should().Contain("using System.Threading.Tasks;"); } + [TestMethod] + public void Diagnostic_AOTSG0002_ReportedForNestedClassInsideGenericOuter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + [TestClass] + public class InnerTests + { + [TestMethod] + public void Test1() { } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0002"); + string registry = GetRegistry(result); + registry.Should().NotContain("InnerTests"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_ReportedForFileLocalClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample; + + [TestClass] + file class FileLocalTests + { + [TestMethod] + public void Test1() { } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003"); + string registry = GetRegistry(result); + registry.Should().NotContain("FileLocalTests"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_ReportedForPrivateNestedClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + [TestClass] + private class HiddenTests + { + [TestMethod] + public void Test1() { } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003"); + string registry = GetRegistry(result); + registry.Should().NotContain("HiddenTests"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_NotReportedForInternalNestedInPublicOuter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + [TestClass] + internal class VisibleTests + { + [TestMethod] + public void Test1() { } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("typeof(global::Sample.Outer.VisibleTests)"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_ReportedWhenOuterIsPrivateNested() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + private class HiddenOuter + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test1() { } + } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003"); + string registry = GetRegistry(result); + registry.Should().NotContain("typeof(global::Sample.Outer.HiddenOuter.Tests)"); + } + + [TestMethod] + public void Diagnostic_AOTSG0004_ReportedForGenericTestMethod_OtherMethodsStillEmitted() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void GenericMethod() { } + + [TestMethod] + public void NormalMethod() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0004"); + string registry = GetRegistry(result); + // The class itself is still emitted because at least one supported member remains. + registry.Should().Contain("typeof(global::Sample.Tests)"); + // The generic method is excluded from the registry; the normal one is present. + registry.Should().NotContain("\"GenericMethod\""); + registry.Should().Contain("\"NormalMethod\""); + } + + [TestMethod] + public void Diagnostic_AOTSG0005_ReportedForByRefParameter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void RefParam(ref int x) { } + + [TestMethod] + public void NormalMethod() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0005"); + string registry = GetRegistry(result); + registry.Should().Contain("typeof(global::Sample.Tests)"); + registry.Should().NotContain("\"RefParam\""); + registry.Should().Contain("\"NormalMethod\""); + } + + [TestMethod] + public void Diagnostic_AOTSG0005_ReportedForOutAndInParameters() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void OutParam(out int x) { x = 0; } + + [TestMethod] + public void InParam(in int x) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Where(d => d.Id == "AOTSG0005").Should().HaveCount(2); + string registry = GetRegistry(result); + registry.Should().NotContain("\"OutParam\""); + registry.Should().NotContain("\"InParam\""); + } + + [TestMethod] + public void Diagnostic_AOTSG0005_ReportedForByRefConstructorParameter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + public Tests() { } + + public Tests(ref int x) { } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0005"); + string registry = GetRegistry(result); + // The valid parameterless constructor is still emitted. + registry.Should().Contain("typeof(global::Sample.Tests)"); + } + + [TestMethod] + public void Diagnostic_NoneReportedForWellFormedTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test1() { } + + [TestMethod] + public void Test2(int x, string y) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From a6d13e46125c07170f1890bf08b84b47cda42a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 17:54:56 +0200 Subject: [PATCH 08/10] Drop init accessors from emitted support types The support types (TestClassReflectionInfo, TestMethodReflectionInfo, TestPropertyReflectionInfo, TestConstructorReflectionInfo) emitted by MSTest.AotReflection.SourceGeneration are new public-shaped API in the consumer assembly (they are emitted as `internal sealed` but exposed through the static `MSTestReflectionMetadata` registry). The repo guideline forbids `init` accessors on newly introduced public API for MSTest and Microsoft.Testing.Platform. Replaces `{ get; init; }` with `{ get; set; }` for every emitted auto-property so the existing object-initializer call sites in the emitter continue to work without other changes. Adds a focused unit test that fails if any `{ get; init; }` reappears in the generated support-types source. Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/MetadataRegistryEmitter.cs | 46 +++++++++---------- .../MSTestReflectionMetadataGeneratorTests.cs | 32 ++++++++++++- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index efead40f59..61bab30475 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -40,46 +40,46 @@ public static string EmitSupportTypes() sb.AppendLine("/// Describes one test class as discovered at compile-time. Mirrors what IReflectionOperations would return at runtime."); using (sb.Block("internal sealed class TestClassReflectionInfo")) { - sb.AppendLine("public Type Type { get; init; } = null!;"); - sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); - sb.AppendLine("public IReadOnlyList Methods { get; init; } = Array.Empty();"); - sb.AppendLine("public IReadOnlyList Properties { get; init; } = Array.Empty();"); - sb.AppendLine("public IReadOnlyList Constructors { get; init; } = Array.Empty();"); + sb.AppendLine("public Type Type { get; set; } = null!;"); + sb.AppendLine("public Attribute[] Attributes { get; set; } = Array.Empty();"); + sb.AppendLine("public IReadOnlyList Methods { get; set; } = Array.Empty();"); + sb.AppendLine("public IReadOnlyList Properties { get; set; } = Array.Empty();"); + sb.AppendLine("public IReadOnlyList Constructors { get; set; } = Array.Empty();"); } sb.AppendLine(); using (sb.Block("internal sealed class TestMethodReflectionInfo")) { - sb.AppendLine("public string Name { get; init; } = string.Empty;"); - sb.AppendLine("public bool IsStatic { get; init; }"); - sb.AppendLine("public bool ReturnsTask { get; init; }"); - sb.AppendLine("public bool ReturnsValueTask { get; init; }"); - sb.AppendLine("public bool ReturnsVoid { get; init; }"); - sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty();"); - sb.AppendLine("public string[] ParameterNames { get; init; } = Array.Empty();"); - sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); + sb.AppendLine("public string Name { get; set; } = string.Empty;"); + sb.AppendLine("public bool IsStatic { get; set; }"); + sb.AppendLine("public bool ReturnsTask { get; set; }"); + sb.AppendLine("public bool ReturnsValueTask { get; set; }"); + sb.AppendLine("public bool ReturnsVoid { get; set; }"); + sb.AppendLine("public Type[] ParameterTypes { get; set; } = Array.Empty();"); + sb.AppendLine("public string[] ParameterNames { get; set; } = Array.Empty();"); + sb.AppendLine("public Attribute[] Attributes { get; set; } = Array.Empty();"); sb.AppendLine("/// Materialized argument tuples from [DataRow] attributes (empty for non-data-driven tests). Each object?[] corresponds to one [DataRow] application."); - sb.AppendLine("public IReadOnlyList DataRows { get; init; } = Array.Empty();"); + sb.AppendLine("public IReadOnlyList DataRows { get; set; } = Array.Empty();"); sb.AppendLine("/// Direct invoker — replaces . Always returns a non-null so the caller can await regardless of whether the underlying test method is void, Task, Task<T>, ValueTask, or ValueTask<T>; the result value (if any) is discarded."); - sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;"); + sb.AppendLine("public Func Invoke { get; set; } = static (_, _) => Task.CompletedTask;"); } sb.AppendLine(); using (sb.Block("internal sealed class TestPropertyReflectionInfo")) { - sb.AppendLine("public string Name { get; init; } = string.Empty;"); - sb.AppendLine("public Type PropertyType { get; init; } = typeof(object);"); - sb.AppendLine("public bool HasPublicSetter { get; init; }"); - sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); - sb.AppendLine("public Func Get { get; init; } = static _ => null;"); - sb.AppendLine("public Action Set { get; init; } = static (_, _) => { };"); + sb.AppendLine("public string Name { get; set; } = string.Empty;"); + sb.AppendLine("public Type PropertyType { get; set; } = typeof(object);"); + sb.AppendLine("public bool HasPublicSetter { get; set; }"); + sb.AppendLine("public Attribute[] Attributes { get; set; } = Array.Empty();"); + sb.AppendLine("public Func Get { get; set; } = static _ => null;"); + sb.AppendLine("public Action Set { get; set; } = static (_, _) => { };"); } sb.AppendLine(); using (sb.Block("internal sealed class TestConstructorReflectionInfo")) { - sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty();"); - sb.AppendLine("public Func Invoke { get; init; } = static _ => throw new InvalidOperationException(\"No constructor registered.\");"); + sb.AppendLine("public Type[] ParameterTypes { get; set; } = Array.Empty();"); + sb.AppendLine("public Func Invoke { get; set; } = static _ => throw new InvalidOperationException(\"No constructor registered.\");"); } } diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 1de711687f..f918e52662 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -1048,7 +1048,8 @@ public void Generator_SupportType_DeclaresInvokeAsTaskReturning() support.Should().Contain("using System.Threading.Tasks;"); // Invoke must be Task-returning so consumers can await without type-testing the result. - support.Should().Contain("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;"); + // Uses `set` (not `init`) per the repo guideline that new public-shaped API must not use init accessors. + support.Should().Contain("public Func Invoke { get; set; } = static (_, _) => Task.CompletedTask;"); } [TestMethod] @@ -1493,6 +1494,35 @@ public void Test2(int x, string y) { } result.Diagnostics.Should().BeEmpty(); } + [TestMethod] + public void Generator_SupportTypes_DoNotUseInitAccessors() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + // Repo guideline: newly introduced public-shaped API (even when emitted as `internal sealed` + // into the consumer assembly) MUST NOT use `init` accessors. Guard against accidental + // reintroduction. + support.Should().NotContain("{ get; init; }"); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 40192e112b39c26ac3ab83cb41a42a992839130d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 10:09:46 +0200 Subject: [PATCH 09/10] Fix generated registry accessibility filtering Restrict emitted method and property metadata to members callable from an unrelated type in the consumer assembly. Protected and private protected members are no longer emitted, while protected internal members remain available through internal access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/TestClassModelBuilder.cs | 4 +- .../MSTestReflectionMetadataGeneratorTests.cs | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 5797846dac..7f4d33a8c7 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -148,9 +148,7 @@ private static bool IsAccessibleFromConsumer(ISymbol symbol) => symbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal - or Accessibility.Protected - or Accessibility.ProtectedOrInternal - or Accessibility.ProtectedAndInternal; + or Accessibility.ProtectedOrInternal; private static string BuildMethodSignatureKey(IMethodSymbol method) { diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index f918e52662..d48add3c7b 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -518,6 +518,58 @@ public void DerivedTest() { } registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute"); } + [TestMethod] + public void Generator_ExcludesProtectedAndPrivateProtectedMembersFromBaseType() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + public class BaseTests + { + [TestMethod] + protected void ProtectedTest() { } + + [TestMethod] + private protected void PrivateProtectedTest() { } + + [TestMethod] + protected internal void ProtectedInternalTest() { } + + [TestContext] + protected TestContext ProtectedContext { get; set; } = new(); + + [TestContext] + private protected TestContext PrivateProtectedContext { get; set; } = new(); + + [TestContext] + protected internal TestContext ProtectedInternalContext { get; set; } = new(); + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().NotContain("Name = \"ProtectedTest\""); + registry.Should().NotContain("Name = \"PrivateProtectedTest\""); + registry.Should().NotContain("Name = \"ProtectedContext\""); + registry.Should().NotContain("Name = \"PrivateProtectedContext\""); + registry.Should().Contain("Name = \"ProtectedInternalTest\""); + registry.Should().Contain("Name = \"ProtectedInternalContext\""); + } + [TestMethod] public void Generator_IncludesMethodsFromMultiLevelInheritance() { From 176736b9f488e165407ff00efe7a82eff03e26e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= <113402822+Evangelink@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:45:40 +0200 Subject: [PATCH 10/10] Update stale BuildModel reference in pipeline comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/MSTestReflectionMetadataGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs index a8e9479ab0..73cd670254 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs @@ -38,7 +38,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) MSTestAttributeNames.TestClass, // Predicate stays cheap and shape-only. Diagnostics for unsupported shapes // (static, generic, inaccessible, generic method, by-ref parameter) are - // computed in BuildModel where we have the full ISymbol. + // computed in BuildResult where we have the full ISymbol. predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (ctx, ct) => BuildResult(ctx, ct));