diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md
index c735aa5d2d..b4d7fb8a2c 100644
--- a/.github/skills/testing/SKILL.md
+++ b/.github/skills/testing/SKILL.md
@@ -13,7 +13,7 @@ Before adding tests, always check whether tests for the same functionality alrea
## Test project overview
-CsWinRT 3.0 has 5 primary test project areas, each serving a different purpose. Additional specialized test projects also exist under `src/Tests/`:
+CsWinRT 3.0 has 6 primary test project areas, each serving a different purpose. Additional specialized test projects also exist under `src/Tests/`:
### 1. Unit tests (`src/Tests/UnitTest/`)
@@ -213,6 +213,45 @@ public async Task InvalidType_Warns()
- **Release x64:** NativeAOT self-contained mode
- Build-time validation (compilation succeeds = test passes)
+### 6. WinMD generator tests (`src/Tests/WinMDGeneratorTest/`)
+
+**What it tests:** End-to-end behavior of the `cswinrtwinmdgen` build tool (the WinMD generator). It focuses on **failure cases** — unsupported authored component shapes and invalid tool invocations — that can be asserted without breaking a real build.
+
+**When to add tests here:** For testing a WinMD generator failure mode — a new `CSWINRTWINMDGEN` validation error, an unsupported authoring pattern, or a malformed/invalid invocation. Authored shapes that produce a valid `.winmd` are covered by the authoring tests (`AuthoringTest/`) instead.
+
+**Project settings:**
+- **Test framework:** MSTest (`[TestClass]`, `[TestMethod]`, `Assert.*`)
+- **TFM:** `net10.0`, multi-platform (x64/x86)
+- **Output type:** Exe, `CsWinRTEnabled=false`
+- **Key dependencies:** MSTest packages, `Basic.Reference.Assemblies.Net100` and `Microsoft.CodeAnalysis.CSharp` (used to compile the C# inputs)
+- **References:** `WinRT.WinMD.Generator` via `ProjectReference` with `ReferenceOutputAssembly="false"` and `GlobalPropertiesToRemove="RuntimeIdentifier"` — the tool is **built but not referenced**, and tests invoke it as a separate process. Its path is passed to the tests via an `AssemblyMetadata` item (`WinMDGeneratorAssemblyPath`).
+
+**Test classes:**
+| Test class | What it tests |
+|------------|---------------|
+| `Test_ParameterConventions` | Unsupported array/span parameter shapes (`ref`/`in` arrays, `out` spans) → `CSWINRTWINMDGEN0011`/`0012`, plus a valid smoke test |
+| `Test_InvalidInputs` | Invalid invocations: malformed/missing response files, bad arguments, missing/corrupt input assemblies, an unwritable output path, a missing debug-repro directory |
+
+**Test helper (in `Helpers/`):**
+- `WinMDGeneratorRunner` — compiles a C# input, runs the actual tool as a subprocess, and asserts the outcome. Public entry points: `AssertSuccess`, `AssertFailure` (from component source or full response-file content), `AssertFailureForMissingResponseFile`, and `CompileComponent`.
+
+**Pattern:**
+```csharp
+[TestMethod]
+public void RefArrayParameter_IsRejected()
+{
+ WinMDGeneratorRunner.AssertFailure("""
+ public interface IComponent
+ {
+ void Method(ref int[] values);
+ }
+ """, error: "CSWINRTWINMDGEN0011");
+}
+```
+
+- Each test is a single `AssertSuccess`/`AssertFailure` call; the runner makes the exit-code and error-output assertions
+- Failure cases assert the tool exits non-zero and its output contains the expected `CSWINRTWINMDGEN` error id
+
## Deciding where to add tests
| You want to test... | Add test to... |
@@ -227,6 +266,7 @@ public async Task InvalidType_Warns()
| GC/reference tracking behavior | `ObjectLifetimeTests/` |
| XAML visual tree element lifetime | `ObjectLifetimeTests/` |
| WinRT component authoring patterns | `AuthoringTest/` (when enabled) |
+| A WinMD generator failure mode (a `CSWINRTWINMDGEN` error) | `WinMDGeneratorTest/` (add to `Test_ParameterConventions` or `Test_InvalidInputs`) |
| Generated projection code patterns or cross-ABI control flow | Update `TestComponentCSharp/` and add tests in `UnitTest/` or `FunctionalTests/` |
## Test component: TestComponentCSharp (`src/Tests/TestComponentCSharp/`)
diff --git a/.github/skills/update-testing-instructions/SKILL.md b/.github/skills/update-testing-instructions/SKILL.md
index 6ffe5ff9c8..a304f09310 100644
--- a/.github/skills/update-testing-instructions/SKILL.md
+++ b/.github/skills/update-testing-instructions/SKILL.md
@@ -76,7 +76,19 @@ Launch an explore agent to verify:
- **"Referenced from" list** is current
- **"When to update" guidance** is still correct
-### Step 8: verify the "deciding where to add tests" table
+### Step 8: verify WinMD generator tests (`src/Tests/WinMDGeneratorTest/`)
+
+Launch an explore agent to analyze the WinMD generator test project. Verify:
+
+- **Project settings** are current: test framework, TFM, platforms, output type, `CsWinRTEnabled` setting
+- **Tool wiring** is accurate: the `WinRT.WinMD.Generator` `ProjectReference` is built but not referenced (`ReferenceOutputAssembly="false"` + `GlobalPropertiesToRemove="RuntimeIdentifier"`), and the `AssemblyMetadata` item (`WinMDGeneratorAssemblyPath`) passes the built tool's path to the tests
+- **Key dependencies** are current (MSTest packages, `Basic.Reference.Assemblies.Net100`, `Microsoft.CodeAnalysis.CSharp`)
+- **Test class table** is complete and accurate — check all `Test_*.cs` files, verify no classes have been added, removed, or renamed, and verify each class's description matches its actual content
+- **Test helper** (`WinMDGeneratorRunner` in `Helpers/`) is accurately described, including its public entry points (`AssertSuccess`, `AssertFailure`, `AssertFailureForMissingResponseFile`, `CompileComponent`)
+- **Pattern example** matches the actual single-call `AssertSuccess`/`AssertFailure` style
+- **Error ids** referenced (`CSWINRTWINMDGEN*`) are still produced by the generator
+
+### Step 9: verify the "deciding where to add tests" table
Check every row in the routing table against the actual test projects. Verify:
@@ -85,7 +97,7 @@ Check every row in the routing table against the actual test projects. Verify:
- No new test project categories exist that should be added as rows
- The `TestComponentCSharp` guidance is still accurate
-### Step 9: verify code examples
+### Step 10: verify code examples
For each code example in the testing skill, verify it matches the conventions actually used in the test files:
@@ -93,8 +105,9 @@ For each code example in the testing skill, verify it matches the conventions ac
- **Functional test pattern**: verify the example uses the correct exit codes and top-level statement style
- **Generator test pattern**: verify the `VerifySources` call matches the actual method signature and style. Check an actual test method in `Test_CustomPropertyProviderGenerator` to confirm
- **Analyzer test pattern**: verify the `VerifyAnalyzerAsync` call matches the actual method signature and style. Check an actual test method in the analyzer test files to confirm
+- **WinMD generator test pattern**: verify the single-call `AssertSuccess`/`AssertFailure` example matches the actual style. Check an actual test method in `Test_ParameterConventions` or `Test_InvalidInputs` to confirm
-### Step 10: update the testing instructions
+### Step 11: update the testing instructions
Apply surgical edits to `.github/skills/testing/SKILL.md` to fix any discrepancies found. Typical updates include:
@@ -115,17 +128,17 @@ Apply surgical edits to `.github/skills/testing/SKILL.md` to fix any discrepanci
- Do not add unnecessary commentary or explanation beyond what's needed for test placement guidance
-### Step 11: update this skill if needed
+### Step 12: update this skill if needed
If significant changes to the test suite were discovered (e.g. test projects added or removed, new categories of tests, changed validation criteria), also update this skill file (`.github/skills/update-testing-instructions/SKILL.md`) to reflect those changes. In particular:
-- The **per-project verification steps** (steps 2–7) must stay in sync with the actual test projects. If a test project is added or removed, add or remove its verification step accordingly.
+- The **per-project verification steps** (steps 2–8) must stay in sync with the actual test projects. If a test project is added or removed, add or remove its verification step accordingly.
- The **verification criteria** for each step should reflect what is actually worth checking. If a project gains new aspects worth validating (e.g. new test helper classes, new build settings), add those to the checklist.
-- The **code example verification step** (step 9) should list all code examples present in the testing skill.
+- The **code example verification step** (step 10) should list all code examples present in the testing skill.
This ensures the skill remains useful and accurate for future runs.
-### Step 12: summarize changes
+### Step 13: summarize changes
After editing, provide a clear summary of what was updated and why, so the user can review the changes before committing.
diff --git a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs
new file mode 100644
index 0000000000..aaf4516f9c
--- /dev/null
+++ b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs
@@ -0,0 +1,226 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Basic.Reference.Assemblies;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Emit;
+
+namespace WinMDGeneratorTest.Helpers;
+
+///
+/// Runs the WinMD generator (cswinrtwinmdgen) end-to-end and asserts its outcome.
+///
+///
+/// Each entry point compiles a small C# input (or builds a raw response file), runs the actual tool as
+/// a separate process, and asserts the result. Tests should call or one of
+/// the AssertFailure overloads so each scenario stays a single call.
+///
+internal static class WinMDGeneratorRunner
+{
+ ///
+ /// Asserts that the generator succeeds (exit code 0) for the given component source.
+ ///
+ /// The C# source defining the authored component types.
+ /// Whether to use the UWP (Windows.UI.Xaml) projections.
+ public static void AssertSuccess(string source, bool useWindowsUIXamlProjections = false)
+ {
+ (int exitCode, string output) = Run(source, useWindowsUIXamlProjections);
+
+ Assert.AreEqual(0, exitCode, output);
+ }
+
+ ///
+ /// Asserts that the generator fails for the given component source, reporting the expected error.
+ ///
+ /// The C# source defining the authored component types.
+ /// The expected error id (e.g. "CSWINRTWINMDGEN0011") the output must contain.
+ /// Whether to use the UWP (Windows.UI.Xaml) projections.
+ public static void AssertFailure(string source, string error, bool useWindowsUIXamlProjections = false)
+ {
+ AssertFailureResult(Run(source, useWindowsUIXamlProjections), error);
+ }
+
+ ///
+ /// Asserts that the generator fails for a caller-provided response file, reporting the expected error.
+ ///
+ ///
+ /// The receives a fresh temporary working directory and
+ /// returns the full response file content to run with (one --argument value pair per line). It
+ /// can use to produce an input assembly, or reference any other path
+ /// under the directory (e.g. to test invalid inputs).
+ ///
+ /// Builds the full response file content for a given temporary directory.
+ /// The expected error id (e.g. "CSWINRTWINMDGEN0002") the output must contain.
+ public static void AssertFailure(Func responseFileFactory, string error)
+ {
+ AssertFailureResult(Run(responseFileFactory), error);
+ }
+
+ ///
+ /// Asserts that the generator fails when pointed at a response file path that does not exist,
+ /// reporting the expected error.
+ ///
+ /// The expected error id (e.g. "CSWINRTWINMDGEN0001") the output must contain.
+ public static void AssertFailureForMissingResponseFile(string error)
+ {
+ string missingResponseFilePath = Path.Combine(Path.GetTempPath(), $"WinMDGeneratorTest_{Guid.NewGuid():N}.rsp");
+
+ AssertFailureResult(RunTool(GetGeneratorPath(), missingResponseFilePath), error);
+ }
+
+ ///
+ /// Compiles the given C# into TestInput.dll in .
+ ///
+ ///
+ /// This is exposed for the
+ /// scenarios that need a valid input assembly before triggering a later-stage failure (e.g. an
+ /// unwritable output path).
+ ///
+ /// The C# source defining the authored component types.
+ /// The directory to emit the assembly into.
+ /// The full path to the compiled assembly.
+ public static string CompileComponent(string source, string directory)
+ {
+ string outputPath = Path.Combine(directory, "TestInput.dll");
+
+ CSharpParseOptions parseOptions = new(LanguageVersion.Preview);
+
+ // The target framework attribute lets the generator probe the .NET runtime version. It is added
+ // in a separate syntax tree so it does not interfere with any 'using' directives in the test
+ // source (an assembly attribute must precede type declarations but follow 'using' directives).
+ SyntaxTree sourceTree = CSharpSyntaxTree.ParseText(source, parseOptions);
+ SyntaxTree assemblyInfoTree = CSharpSyntaxTree.ParseText(
+ """[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0")]""",
+ parseOptions);
+
+ CSharpCompilation compilation = CSharpCompilation.Create(
+ assemblyName: "TestInput",
+ syntaxTrees: [sourceTree, assemblyInfoTree],
+ references: Net100.References.All,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true));
+
+ EmitResult result = compilation.Emit(outputPath);
+
+ Assert.IsTrue(result.Success, $"Input compilation failed:\n{string.Join("\n", result.Diagnostics)}");
+
+ return outputPath;
+ }
+
+ ///
+ /// Compiles the given C# into an assembly and runs the WinMD generator
+ /// against it with a standard response file.
+ ///
+ private static (int ExitCode, string Output) Run(string source, bool useWindowsUIXamlProjections)
+ {
+ return Run(temporaryDirectory =>
+ {
+ string inputAssemblyPath = CompileComponent(source, temporaryDirectory);
+
+ return $"""
+ --input-assembly-path {inputAssemblyPath}
+ --reference-assembly-paths {inputAssemblyPath}
+ --output-winmd-path {Path.Combine(temporaryDirectory, "TestInput.winmd")}
+ --assembly-version 1.0.0.0
+ --use-windows-ui-xaml-projections {useWindowsUIXamlProjections}
+ """;
+ });
+ }
+
+ ///
+ /// Runs the WinMD generator against a caller-provided response file.
+ ///
+ private static (int ExitCode, string Output) Run(Func responseFileFactory)
+ {
+ string toolPath = GetGeneratorPath();
+ string temporaryDirectory = Directory.CreateTempSubdirectory("WinMDGeneratorTest_").FullName;
+
+ try
+ {
+ string responseFileContent = responseFileFactory(temporaryDirectory);
+ string responseFilePath = Path.Combine(temporaryDirectory, "args.rsp");
+
+ File.WriteAllText(responseFilePath, responseFileContent);
+
+ return RunTool(toolPath, responseFilePath);
+ }
+ finally
+ {
+ TryDeleteDirectory(temporaryDirectory);
+ }
+ }
+
+ ///
+ /// Asserts that a generator run failed (non-zero exit code) and its output contains the expected error.
+ ///
+ private static void AssertFailureResult((int ExitCode, string Output) result, string error)
+ {
+ Assert.AreNotEqual(0, result.ExitCode, result.Output);
+ StringAssert.Contains(result.Output, error);
+ }
+
+ ///
+ /// Runs dotnet exec <tool> <argument> and captures the exit code and output.
+ ///
+ private static (int ExitCode, string Output) RunTool(string toolPath, string argument)
+ {
+ ProcessStartInfo startInfo = new("dotnet")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ startInfo.ArgumentList.Add("exec");
+ startInfo.ArgumentList.Add(toolPath);
+ startInfo.ArgumentList.Add(argument);
+
+ using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start the WinMD generator process.");
+
+ string standardOutput = process.StandardOutput.ReadToEnd();
+ string standardError = process.StandardError.ReadToEnd();
+
+ process.WaitForExit();
+
+ return (process.ExitCode, standardOutput + standardError);
+ }
+
+ ///
+ /// Resolves the path to the built cswinrtwinmdgen tool from assembly metadata.
+ ///
+ private static string GetGeneratorPath()
+ {
+ string? path = typeof(WinMDGeneratorRunner).Assembly
+ .GetCustomAttributes()
+ .FirstOrDefault(static attribute => attribute.Key == "WinMDGeneratorAssemblyPath")?.Value;
+
+ Assert.IsFalse(string.IsNullOrEmpty(path), "The 'WinMDGeneratorAssemblyPath' assembly metadata was not found.");
+
+ string fullPath = Path.GetFullPath(path!);
+
+ Assert.IsTrue(File.Exists(fullPath), $"The WinMD generator was not found at '{fullPath}'.");
+
+ return fullPath;
+ }
+
+ ///
+ /// Deletes a directory recursively, ignoring failures (best effort cleanup).
+ ///
+ private static void TryDeleteDirectory(string directory)
+ {
+ try
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Best effort cleanup; a leftover temp directory must not fail the test
+ }
+ }
+}
diff --git a/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs
new file mode 100644
index 0000000000..d48a91e36c
--- /dev/null
+++ b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.IO;
+using WinMDGeneratorTest.Helpers;
+
+namespace WinMDGeneratorTest;
+
+///
+/// End-to-end tests for the WinMD generator's handling of invalid invocations: malformed or missing
+/// response files, bad arguments, missing or corrupt input assemblies, an unwritable output path, and
+/// a missing debug-repro directory.
+///
+///
+/// Each test runs the actual cswinrtwinmdgen tool and asserts a non-zero exit code and the
+/// expected CSWINRTWINMDGEN error in the output.
+///
+[TestClass]
+public class Test_InvalidInputs
+{
+ [TestMethod]
+ public void MissingResponseFile_IsReported()
+ {
+ WinMDGeneratorRunner.AssertFailureForMissingResponseFile(error: "CSWINRTWINMDGEN0001");
+ }
+
+ [TestMethod]
+ public void MalformedResponseFile_IsReported()
+ {
+ // A response file line must be a ' ' pair; a line without a space is invalid
+ WinMDGeneratorRunner.AssertFailure(static _ => """
+ --assembly-version 1.0.0.0
+ this-line-has-no-space
+ """, error: "CSWINRTWINMDGEN0002");
+ }
+
+ [TestMethod]
+ public void DuplicateArgument_IsReported()
+ {
+ WinMDGeneratorRunner.AssertFailure(static _ => """
+ --assembly-version 1.0.0.0
+ --assembly-version 2.0.0.0
+ """, error: "CSWINRTWINMDGEN0002");
+ }
+
+ [TestMethod]
+ public void MissingRequiredArgument_IsReported()
+ {
+ // The required '--output-winmd-path' argument is omitted
+ WinMDGeneratorRunner.AssertFailure(static _ => """
+ --input-assembly-path input.dll
+ --reference-assembly-paths input.dll
+ --assembly-version 1.0.0.0
+ --use-windows-ui-xaml-projections False
+ """, error: "CSWINRTWINMDGEN0003");
+ }
+
+ [TestMethod]
+ public void InvalidBooleanArgument_IsReported()
+ {
+ WinMDGeneratorRunner.AssertFailure(static _ => """
+ --input-assembly-path input.dll
+ --reference-assembly-paths input.dll
+ --output-winmd-path output.winmd
+ --assembly-version 1.0.0.0
+ --use-windows-ui-xaml-projections not-a-boolean
+ """, error: "CSWINRTWINMDGEN0003");
+ }
+
+ [TestMethod]
+ public void MissingInputAssembly_IsReported()
+ {
+ WinMDGeneratorRunner.AssertFailure(static temporaryDirectory =>
+ {
+ string missingInput = Path.Combine(temporaryDirectory, "DoesNotExist.dll");
+
+ return $"""
+ --input-assembly-path {missingInput}
+ --reference-assembly-paths {missingInput}
+ --output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}
+ --assembly-version 1.0.0.0
+ --use-windows-ui-xaml-projections False
+ """;
+ }, error: "CSWINRTWINMDGEN0004");
+ }
+
+ [TestMethod]
+ public void InvalidInputAssembly_IsReported()
+ {
+ WinMDGeneratorRunner.AssertFailure(static temporaryDirectory =>
+ {
+ string invalidInput = Path.Combine(temporaryDirectory, "NotAPortableExecutable.dll");
+
+ File.WriteAllText(invalidInput, "This is not a valid PE image.");
+
+ return $"""
+ --input-assembly-path {invalidInput}
+ --reference-assembly-paths {invalidInput}
+ --output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}
+ --assembly-version 1.0.0.0
+ --use-windows-ui-xaml-projections False
+ """;
+ }, error: "CSWINRTWINMDGEN0004");
+ }
+
+ [TestMethod]
+ public void UnwritableOutputPath_IsReported()
+ {
+ // The output path points at an existing directory, so the '.winmd' cannot be written
+ WinMDGeneratorRunner.AssertFailure(static temporaryDirectory =>
+ {
+ string inputAssemblyPath = WinMDGeneratorRunner.CompileComponent("""
+ public interface IComponent
+ {
+ int Method(int value);
+ }
+ """, temporaryDirectory);
+
+ return $"""
+ --input-assembly-path {inputAssemblyPath}
+ --reference-assembly-paths {inputAssemblyPath}
+ --output-winmd-path {temporaryDirectory}
+ --assembly-version 1.0.0.0
+ --use-windows-ui-xaml-projections False
+ """;
+ }, error: "CSWINRTWINMDGEN0006");
+ }
+
+ [TestMethod]
+ public void MissingDebugReproDirectory_IsReported()
+ {
+ WinMDGeneratorRunner.AssertFailure(static temporaryDirectory =>
+ {
+ string inputAssemblyPath = WinMDGeneratorRunner.CompileComponent("""
+ public interface IComponent
+ {
+ int Method(int value);
+ }
+ """, temporaryDirectory);
+
+ string missingDebugReproDirectory = Path.Combine(temporaryDirectory, "does-not-exist");
+
+ return $"""
+ --input-assembly-path {inputAssemblyPath}
+ --reference-assembly-paths {inputAssemblyPath}
+ --output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}
+ --assembly-version 1.0.0.0
+ --use-windows-ui-xaml-projections False
+ --debug-repro-directory {missingDebugReproDirectory}
+ """;
+ }, error: "CSWINRTWINMDGEN0008");
+ }
+}
diff --git a/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs b/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs
new file mode 100644
index 0000000000..049ae24e56
--- /dev/null
+++ b/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using WinMDGeneratorTest.Helpers;
+
+namespace WinMDGeneratorTest;
+
+///
+/// End-to-end tests for the WinMD generator's validation of unsupported method parameter conventions.
+///
+///
+/// These tests run the actual cswinrtwinmdgen tool and assert that unsupported array/span
+/// parameter shapes are rejected with the expected error. Keeping the failure cases here lets us
+/// exercise them without breaking the build. The supported conventions that produce a valid
+/// .winmd (PassArray/FillArray/ReceiveArray) are covered by the authoring tests instead.
+///
+[TestClass]
+public class Test_ParameterConventions
+{
+ [TestMethod]
+ public void ValidComponent_GeneratesSuccessfully()
+ {
+ // Smoke test: a valid component generates a '.winmd', validating the end-to-end harness
+ WinMDGeneratorRunner.AssertSuccess("""
+ public interface IComponent
+ {
+ int Method(int value);
+ }
+ """);
+ }
+
+ [TestMethod]
+ public void RefArrayParameter_IsRejected()
+ {
+ WinMDGeneratorRunner.AssertFailure("""
+ public interface IComponent
+ {
+ void Method(ref int[] values);
+ }
+ """, error: "CSWINRTWINMDGEN0011");
+ }
+
+ [TestMethod]
+ public void InArrayParameter_IsRejected()
+ {
+ // 'in int[]' carries a 'modreq(InAttribute)' on interface members; the generator must still
+ // see through it to detect the by-reference array.
+ WinMDGeneratorRunner.AssertFailure("""
+ public interface IComponent
+ {
+ void Method(in int[] values);
+ }
+ """, error: "CSWINRTWINMDGEN0011");
+ }
+
+ [TestMethod]
+ public void OutSpanParameter_IsRejected()
+ {
+ WinMDGeneratorRunner.AssertFailure("""
+ using System;
+ public interface IComponent
+ {
+ void Method(out Span values);
+ }
+ """, error: "CSWINRTWINMDGEN0012");
+ }
+
+ [TestMethod]
+ public void OutReadOnlySpanParameter_IsRejected()
+ {
+ WinMDGeneratorRunner.AssertFailure("""
+ using System;
+ public interface IComponent
+ {
+ void Method(out ReadOnlySpan values);
+ }
+ """, error: "CSWINRTWINMDGEN0012");
+ }
+}
diff --git a/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj
new file mode 100644
index 0000000000..bc2bb655ff
--- /dev/null
+++ b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj
@@ -0,0 +1,68 @@
+
+
+ Exe
+ net10.0
+ x64;x86
+ preview
+ enable
+ false
+
+
+ false
+
+
+
+ win-x86
+ x86
+
+
+
+ win-x64
+ x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs
index d958c0c7a9..27e5e752c7 100644
--- a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs
+++ b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs
@@ -91,6 +91,28 @@ public static Exception DebugReproUnrecognizedFileEntry(string path)
return Exception(10, WellKnownGeneratorMessages.DebugReproUnrecognizedFileEntry(path));
}
+ ///
+ /// A method has a ref or in array parameter, which is not a valid Windows Runtime array convention.
+ ///
+ public static Exception ByReferenceArrayParameterNotSupported(string declaringTypeName, string methodName, string parameterName)
+ {
+ return Exception(11,
+ $"Method '{declaringTypeName}.{methodName}' has by-reference array parameter '{parameterName}' passed by 'ref' or 'in'. " +
+ $"Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' for a read-only input array (PassArray), " +
+ $"'Span' for a caller-allocated array (FillArray), or 'out T[]' for a callee-allocated array (ReceiveArray).");
+ }
+
+ ///
+ /// A method has a by-reference span parameter (e.g. out Span<T>), which has no Windows Runtime representation.
+ ///
+ public static Exception ByReferenceSpanParameterNotSupported(string declaringTypeName, string methodName, string parameterName)
+ {
+ return Exception(12,
+ $"Method '{declaringTypeName}.{methodName}' has by-reference span parameter '{parameterName}'." +
+ $"Windows Runtime spans are passed by value: use 'ReadOnlySpan' (PassArray) or 'Span' " +
+ $"(FillArray) by value, or 'out T[]' for a callee-allocated array (ReceiveArray).");
+ }
+
///
/// Creates a new exception with the specified id and message.
///
diff --git a/src/WinRT.WinMD.Generator/Extensions/TypeSignatureExtensions.cs b/src/WinRT.WinMD.Generator/Extensions/TypeSignatureExtensions.cs
new file mode 100644
index 0000000000..20e00e309e
--- /dev/null
+++ b/src/WinRT.WinMD.Generator/Extensions/TypeSignatureExtensions.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AsmResolver.DotNet.Signatures;
+
+namespace WindowsRuntime.WinMDGenerator;
+
+///
+/// Extension methods for .
+///
+internal static class TypeSignatureExtensions
+{
+ extension(TypeSignature signature)
+ {
+ ///
+ /// Strips any leading custom modifiers (modreq/modopt) from the signature.
+ ///
+ ///
+ /// Custom modifiers carry no Windows Runtime meaning. They are emitted by the C# compiler in a few
+ /// cases, most notably the modreq(System.Runtime.InteropServices.InAttribute) applied to the
+ /// by-reference type of an in parameter on abstract, virtual, interface, or delegate members.
+ ///
+ /// The underlying with all leading custom modifiers removed.
+ public TypeSignature StripCustomModifiers()
+ {
+ TypeSignature current = signature;
+
+ while (current is CustomModifierTypeSignature customModifier)
+ {
+ current = customModifier.BaseType;
+ }
+
+ return current;
+ }
+
+ ///
+ /// Checks whether the signature is some type.
+ ///
+ /// if the signature is ; otherwise, .
+ public bool IsTypeOfSpan()
+ {
+ return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.Span`1" };
+ }
+
+ ///
+ /// Checks whether the signature is some type.
+ ///
+ /// if the signature is ; otherwise, .
+ public bool IsTypeOfReadOnlySpan()
+ {
+ return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.ReadOnlySpan`1" };
+ }
+ }
+}
diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs
index 283f6ef415..521982d003 100644
--- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs
+++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs
@@ -6,6 +6,7 @@
using AsmResolver.DotNet;
using AsmResolver.DotNet.Signatures;
using AsmResolver.PE.DotNet.Metadata.Tables;
+using WindowsRuntime.WinMDGenerator.Errors;
using MethodAttributes = AsmResolver.PE.DotNet.Metadata.Tables.MethodAttributes;
using MethodImplAttributes = AsmResolver.PE.DotNet.Metadata.Tables.MethodImplAttributes;
using MethodSemanticsAttributes = AsmResolver.PE.DotNet.Metadata.Tables.MethodSemanticsAttributes;
@@ -141,6 +142,11 @@ private void AddMethodToClass(TypeDefinition outputType, MethodDefinition inputM
/// - Any other by-reference type (e.g. ref Guid on a COM interop interface) → [in], matching the MIDL convention for ref const T parameters.
/// - All other params → [in].
///
+ ///
+ /// Array and span shapes that have no Windows Runtime representation are rejected with a well-known
+ /// error: a by-reference span (e.g. out Span<T>), or a ref/in array (only
+ /// out T[] is a valid by-reference array). See .
+ ///
///
private static void AddParameterDefinitions(MethodDefinition outputMethod, MethodDefinition inputMethod)
{
@@ -154,7 +160,7 @@ private static void AddParameterDefinitions(MethodDefinition outputMethod, Metho
if (sigIndex < inputParamTypes.Count)
{
- paramattributes = GetWinRTParameterAttributes(inputParam, inputParamTypes[sigIndex]);
+ paramattributes = GetWinRTParameterAttributes(inputMethod, inputParam, inputParamTypes[sigIndex]);
}
outputMethod.ParameterDefinitions.Add(new ParameterDefinition(
@@ -165,15 +171,57 @@ private static void AddParameterDefinitions(MethodDefinition outputMethod, Metho
}
///
- /// Determines the Windows Runtime parameter attributes based on the input parameter and its type.
+ /// Determines the Windows Runtime parameter attributes based on the input parameter and its type,
+ /// validating that the parameter uses a supported Windows Runtime calling convention.
///
///
/// If the input parameter already has or
/// set, those flags are preserved unchanged. Otherwise,
/// the type drives the default per the rules documented on .
+ /// By-reference spans (e.g. out Span<T>) and ref/in arrays have no
+ /// Windows Runtime representation and throw a .
///
- private static ParameterAttributes GetWinRTParameterAttributes(ParameterDefinition inputParam, TypeSignature inputParamType)
+ /// The method that declares the parameter (used for error context).
+ /// The input (provides the In/Out direction flags).
+ /// The input parameter .
+ /// The Windows Runtime for the parameter.
+ /// Thrown if the parameter uses an unsupported array or span convention.
+ private static ParameterAttributes GetWinRTParameterAttributes(
+ MethodDefinition inputMethod,
+ ParameterDefinition inputParam,
+ TypeSignature inputParamType)
{
+ // Look through any custom modifiers (e.g. the 'modreq(InAttribute)' emitted for 'in' parameters
+ // on abstract, virtual, interface, or delegate members) to inspect the real underlying signature
+ TypeSignature parameterType = inputParamType.StripCustomModifiers();
+
+ // Validate by-reference parameters that wrap a span or array. Windows Runtime spans are always
+ // passed by value, and Windows Runtime arrays use one of three conventions: 'ReadOnlySpan'
+ // (PassArray), 'Span' (FillArray), or 'out T[]' (ReceiveArray). A by-reference span, or a
+ // 'ref'/'in' array, has no Windows Runtime representation and is rejected here.
+ if (parameterType is ByReferenceTypeSignature byReference)
+ {
+ TypeSignature elementType = byReference.BaseType.StripCustomModifiers();
+
+ if (elementType.IsTypeOfSpan() || elementType.IsTypeOfReadOnlySpan())
+ {
+ throw WellKnownWinMDExceptions.ByReferenceSpanParameterNotSupported(
+ inputMethod.DeclaringType!.FullName,
+ inputMethod.Name!.Value,
+ inputParam.Name!.Value);
+ }
+
+ // 'out T[]' (ReceiveArray) is valid, but 'ref T[]'/'in T[]' are not. By-reference non-array
+ // types (e.g. 'ref Guid riid' on a COM interop interface) are still allowed and handled below.
+ if (elementType is SzArrayTypeSignature && !inputParam.IsOut)
+ {
+ throw WellKnownWinMDExceptions.ByReferenceArrayParameterNotSupported(
+ inputMethod.DeclaringType!.FullName,
+ inputMethod.Name!.Value,
+ inputParam.Name!.Value);
+ }
+ }
+
// Preserve any 'In'/'Out' direction flags the input parameter already carries
ParameterAttributes inputDirectionFlags = inputParam.Attributes & (ParameterAttributes.In | ParameterAttributes.Out);
@@ -183,8 +231,7 @@ private static ParameterAttributes GetWinRTParameterAttributes(ParameterDefiniti
}
// 'Span' → 'FillArray' pattern: '[out]' without 'BYREF'
- if (inputParamType is GenericInstanceTypeSignature genericInstanceSignature &&
- genericInstanceSignature.GenericType.FullName == "System.Span`1")
+ if (parameterType.IsTypeOfSpan())
{
return ParameterAttributes.Out;
}
diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs
index 07a492aa10..530f9ae03c 100644
--- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs
+++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs
@@ -38,6 +38,14 @@ internal sealed partial class WinMDWriter
/// The mapped for use in the output WinMD.
private TypeSignature MapTypeSignatureToOutput(TypeSignature inputSignature)
{
+ // Strip custom modifiers ('modreq'/'modopt') before dispatching on the underlying type.
+ // For example, 'in' parameters on abstract, virtual, interface, or delegate members carry a
+ // 'modreq(System.Runtime.InteropServices.InAttribute)' on their by-reference type. Custom
+ // modifiers have no Windows Runtime representation, and without stripping them a modifier-wrapped
+ // type would fall through to the 'System.Object' fallback at the end of this method, silently
+ // erasing the real signature.
+ inputSignature = inputSignature.StripCustomModifiers();
+
// Handle 'CorLib' types
if (inputSignature is CorLibTypeSignature corLib)
{
diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs
index 15854de6bc..95fed84256 100644
--- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs
+++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs
@@ -713,7 +713,7 @@ private void AddExplicitInterfaceImplementations(TypeDefinition inputType, TypeD
for (int i = 0; i < paramNames.Length; i++)
{
ParameterAttributes paramAttr = i < parameterTypes.Length && i < method.ParameterDefinitions.Count
- ? GetWinRTParameterAttributes(method.ParameterDefinitions[i], method.Signature!.ParameterTypes[i])
+ ? GetWinRTParameterAttributes(method, method.ParameterDefinitions[i], method.Signature!.ParameterTypes[i])
: ParameterAttributes.In;
outputMethod.ParameterDefinitions.Add(new ParameterDefinition(
diff --git a/src/cswinrt.slnx b/src/cswinrt.slnx
index 27e04f3c4e..d5e936b2ef 100644
--- a/src/cswinrt.slnx
+++ b/src/cswinrt.slnx
@@ -309,6 +309,14 @@
+
+
+
+
+
+
+
+