fix(generator): place CallerArgumentExpression before params in [GenerateAssertion] emit#5940
Conversation
…rateAssertion] emit The MethodAssertionGenerator extension-method emit walked AdditionalParameters twice: first emitting source parameters (including the params modifier in place), then appending [CallerArgumentExpression] diagnostic parameters at the end while skipping the params parameter itself. The skip prevented a CallerArgumentExpression from landing ON the params parameter, but did not prevent diagnostic parameters for OTHER source parameters from landing AFTER it, which violates C#'s "params must be the last parameter" rule and produced CS0231 for any source method shaped like (T first, params T[] rest). Split the walk into three passes so non-params source parameters and their diagnostic counterparts are emitted ahead of the params parameter: non-params source params, then their CallerArgumentExpression diagnostics, then any params source param last. IsIn / IsNotIn in GenericAssertions.cs are the only production [GenerateAssertion] methods that use params. Both declare ONLY the params parameter (no preceding non-params source parameter), so the previous emit was already legal C# for them and the new emit produces a byte-identical signature. PublicAPI snapshots therefore do not change. Regression coverage: TestData/ParamsParameterAssertion.cs exercises six edge cases (one required + params; two required + params; optional defaulted + params; generic + generic params; InlineMethodBody + params; params-only regression baseline). The new test pins each emitted signature with inline .Contains(...) substrings and parses the generator output with CompileChecker as a structural CS0231 sentinel.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| CodeStyle | 1 minor |
🟢 Metrics 0 complexity
Metric Results Complexity 0
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
…rtion IsOneOfWithDefault body Replaces `fallback != null` (Codacy MEDIUM "Error prone": comparison against null on an unconstrained generic produces category-dependent behaviour for value vs reference types) with `EqualityComparer<T>.Default.Equals(fallback, value)` in the IsOneOfWithDefault test-fixture body. The change is in the test fixture only; the snapshot is byte-identical because IsOneOfWithDefault is not marked InlineMethodBody, so its body is never reproduced in the generated extension method that the snapshot captures. CompileChecker and the six edge-case content assertions all stay green. The MINOR "partial is gratuitous" finding on the same file is a precedent- dismissable false positive (the source generator emits the second partial half of the class, so the keyword is required to avoid CS0260). Left as-is to match the convention of every other TestData file in the directory.
|
Addressed in ef76e33.
|
There was a problem hiding this comment.
Code Review
This is a clean, well-scoped bug fix that correctly addresses the CS0231 compile error produced when [GenerateAssertion] is applied to methods whose signatures include both regular parameters and a params array.
What the fix does
The three-pass rewrite in MethodAssertionGenerator.GenerateExtensionMethod (lines 1046–1506 of the diff) is the right approach:
- Emit non-
paramssource parameters (with their default values). - Emit their
[CallerArgumentExpression]counterparts. - Emit the
paramsparameter last.
This correctly satisfies CS0231 while keeping the parameter ordering semantically sound.
Issues found
1. Three separate foreach passes over the same list — consider a single pass
The fix uses three sequential foreach loops over data.AdditionalParameters, each filtering by IsParams. This works correctly, but it scans the list three times and scatters the logic for "what does one parameter contribute to the signature" across three locations. A single pass using a deferred append (e.g. tracking the params entry separately before the loop and appending it after) would be both faster and easier to reason about:
ParameterData? paramsParam = null;
foreach (var param in data.AdditionalParameters)
{
if (param.IsParams) { paramsParam = param; continue; }
sb.Append($", {param.Type} {param.Name}");
if (param.HasExplicitDefaultValue)
sb.Append($" = {param.DefaultValueExpression}");
}
foreach (var param in data.AdditionalParameters)
{
if (!param.IsParams)
sb.Append($", [CallerArgumentExpression(nameof({param.Name}))] string? {param.Name}Expression = null");
}
if (paramsParam is not null)
sb.Append($", params {paramsParam.Type} {paramsParam.Name}");This still requires two passes, but reduces the third loop entirely and makes the structure of the signature explicit: regular params → diagnostic params → the single params param. It also makes it clear the generator only handles at most one params parameter (which C# itself enforces, but the code doesn't currently document).
2. The generator silently accepts multiple params parameters
data.AdditionalParameters is built from IMethodSymbol.Parameters, and C# enforces that there can be at most one params parameter. However, the generator itself never validates or documents this assumption. If this code is ever touched again, a reader won't immediately know that the third pass always appends zero or one entry. A comment or a Debug.Assert would make this explicit.
3. Snapshot duplication — four identical .verified.txt files
MethodAssertionGeneratorTests.ParamsParameter.DotNet10_0.verified.txt, …DotNet8_0…, …DotNet9_0…, and …Net4_7… are byte-identical. The PR description explains this correctly ("the generator emit is TFM-agnostic"). This is fine and consistent with how the existing tests work in this project — just noting it so reviewers are not surprised.
4. Minor: StartsWithAny test body is a tautology
In TestData/ParamsParameterAssertion.cs (line 1450 of the diff), the InlineMethodBody = true test method body is:
=> suffixes.Length >= 0 && value.StartsWith(prefix);suffixes.Length >= 0 is always true for an array, so this test case never actually exercises the suffixes parameter's effect on the result. For a regression fixture this is fine (the test is about compile-correctness and signature order, not semantic correctness), but it's slightly misleading. A comment explaining this intent — or using suffixes.Contains(value[..prefix.Length]) or similar — would improve clarity. This is a minor style observation, not a blocking concern.
Overall assessment
The core fix is correct. The three-loop structure is a very minor maintainability concern. Tests cover all meaningful shapes (single, multiple, optional, generic, inline, params-only) and the CompileChecker.AssertNoErrors structural gate is an excellent addition that will catch future regressions the content-only assertions cannot.
This PR is approved with the optional suggestion to collapse the third foreach loop into the first for clarity.
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.44.0 to 1.45.8. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.45.8 <!-- Release notes generated using configuration in .github/release.yml at v1.45.8 --> ## What's Changed ### Other Changes * fix(aspire): route CreateHttpClient through IHttpClientFactory by @thomhurst in thomhurst/TUnit#5957 ### Dependencies * chore(deps): update tunit to 1.45.0 by @thomhurst in thomhurst/TUnit#5949 * chore(deps): update dependency dompurify to v3.4.5 by @thomhurst in thomhurst/TUnit#5951 * chore(deps): update dependency microsoft.testing.extensions.codecoverage to 18.7.0 by @thomhurst in thomhurst/TUnit#5953 * chore(deps): update dependency coverlet.collector to 10.0.1 by @thomhurst in thomhurst/TUnit#5952 * chore(deps): update dependency polyfill to 10.6.0 by @thomhurst in thomhurst/TUnit#5955 * chore(deps): update dependency polyfill to 10.6.0 by @thomhurst in thomhurst/TUnit#5954 **Full Changelog**: thomhurst/TUnit@v1.45.0...v1.45.8 ## 1.45.0 <!-- Release notes generated using configuration in .github/release.yml at v1.45.0 --> ## What's Changed ### Other Changes * fix(generator): place CallerArgumentExpression before params in [GenerateAssertion] emit by @JohnVerheij in thomhurst/TUnit#5940 * fix(sourcegen): drop covariant TActual when [GenerateAssertion] method has its own type parameters by @JohnVerheij in thomhurst/TUnit#5935 * feat(assertions): add CancellationToken overload to WaitsFor and Eventually by @JohnVerheij in thomhurst/TUnit#5938 * fix(html-report): extract categories using MTP Key=name convention by @thomhurst in thomhurst/TUnit#5946 * feat(html-report): rewrite as split-pane design template by @thomhurst in thomhurst/TUnit#5947 ### Dependencies * chore(deps): update microsoft.testing to 2.2.3 by @thomhurst in thomhurst/TUnit#5927 * chore(deps): update mstest to 4.2.3 by @thomhurst in thomhurst/TUnit#5928 * chore(deps): update tunit to 1.44.39 by @thomhurst in thomhurst/TUnit#5929 * chore(deps): update aspire to 13.3.3 by @thomhurst in thomhurst/TUnit#5933 * chore(deps): update dependency dompurify to v3.4.4 by @thomhurst in thomhurst/TUnit#5944 * chore(deps): update dependency qs to v6.15.2 by @thomhurst in thomhurst/TUnit#5941 **Full Changelog**: thomhurst/TUnit@v1.44.39...v1.45.0 ## 1.44.39 <!-- Release notes generated using configuration in .github/release.yml at v1.44.39 --> ## What's Changed ### Other Changes * fix(tests): retry trx read to dodge MTP post-exit flush race on Windows by @thomhurst in thomhurst/TUnit#5888 * fix(pipeline): timeout + retry InstallPlaywrightModule so a hung download fails fast by @thomhurst in thomhurst/TUnit#5889 * fix(otel): require two consecutive idle windows in DrainAsync to catch in-transit POSTs by @thomhurst in thomhurst/TUnit#5890 * test(assertions): drop flaky wall-clock upper bound on WaitsFor timeout test by @thomhurst in thomhurst/TUnit#5886 * fix(sourcegen): drop spurious ')' in MethodAssertionGenerator Task<bool> emit by @JohnVerheij in thomhurst/TUnit#5920 * fix(sourcegen): merge generic parameter lists in [AssertionExtension] emit by @JohnVerheij in thomhurst/TUnit#5921 * fix(aspnetcore): scope correlation processor per-factory to stop cross-factory tag leak by @thomhurst in thomhurst/TUnit#5891 * Changed FSharp.Core version to 10.1.300 by @licon4812 in thomhurst/TUnit#5909 * feat(mocks): add Mock.HttpClientFactory() helper by @thomhurst in thomhurst/TUnit#5894 * Harden WaitsFor timeout test by @thomhurst in thomhurst/TUnit#5926 * fix(sourcegen): emit `default` literal for value-type assertion parameters by @JohnVerheij in thomhurst/TUnit#5919 ### Dependencies * chore(deps): update dependency nunit to 4.6.0 by @thomhurst in thomhurst/TUnit#5826 * chore(deps): update tunit to 1.44.0 by @thomhurst in thomhurst/TUnit#5882 * chore(deps): update dependency mockolate to 3.2.0 by @thomhurst in thomhurst/TUnit#5892 * chore(deps): update dependency yaml to v2.9.0 by @thomhurst in thomhurst/TUnit#5887 * chore(deps): update dependency nuget.protocol to 7.6.0 by @thomhurst in thomhurst/TUnit#5897 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.8 by @thomhurst in thomhurst/TUnit#5898 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.300 by @thomhurst in thomhurst/TUnit#5899 * chore(deps): update microsoft.extensions by @thomhurst in thomhurst/TUnit#5905 * chore(deps): update microsoft.aspnetcore to 10.0.8 by @thomhurst in thomhurst/TUnit#5904 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.300 by @thomhurst in thomhurst/TUnit#5902 * chore(deps): update aspire to 13.3.1 by @thomhurst in thomhurst/TUnit#5900 * chore(deps): update dependency system.commandline to 2.0.8 by @thomhurst in thomhurst/TUnit#5903 * chore(deps): update dependency azure.storage.blobs to 12.28.0 by @thomhurst in thomhurst/TUnit#5910 * chore(deps): update dependency dotnet-sdk to v10.0.300 by @thomhurst in thomhurst/TUnit#5901 * chore(deps): update dependency stackexchange.redis to 2.13.1 by @thomhurst in thomhurst/TUnit#5906 * chore(deps): update aspire to 13.3.2 by @thomhurst in thomhurst/TUnit#5924 * chore(deps): bump mermaid from 11.12.2 to 11.15.0 in /docs by @dependabot[bot] in thomhurst/TUnit#5893 * chore(deps): update dependency streamjsonrpc to 2.24.92 by @thomhurst in thomhurst/TUnit#5915 * chore(deps): update dependency dompurify to v3.4.3 by @thomhurst in thomhurst/TUnit#5913 * chore(deps): update microsoft.build to 18.6.3 by @thomhurst in thomhurst/TUnit#5914 **Full Changelog**: thomhurst/TUnit@v1.44.0...v1.44.39 Commits viewable in [compare view](thomhurst/TUnit@v1.44.0...v1.45.8). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Description
Reorders the
[GenerateAssertion]source generator emit so the auto-supplied[CallerArgumentExpression]parameters are placed BEFORE anyparamsparameter in the generated extension method signature. The previous emit appended them AFTER theparamsarray, which violates C#'s "params must be the last parameter" rule and producederror CS0231for any source method shaped like(T first, params T[] rest).Implementation
MethodAssertionGenerator.GenerateExtensionMethodpreviously walkeddata.AdditionalParameterstwice: once to emit the source parameters (including theparamsmodifier in place), then once to emit[CallerArgumentExpression]diagnostic parameters at the end (skipping theparamsparameter itself). The skip stopped a CallerArgumentExpression from landing ON theparamsparameter, but did not stop diagnostic parameters for OTHER source parameters from landing AFTER it, which is the CS0231 trigger.The fix splits the walk into three passes:
[CallerArgumentExpression(nameof(...))]for each non-params source parameter.paramssource parameter last.The constructor invocation and the expression-builder string already use the source order independently of the signature emission, so no other emit sites change.
Behaviour for existing assertions
IsIn/IsNotIninGenericAssertions.csare the only production[GenerateAssertion]methods that useparams. Both have ONLY theparamsparameter (no preceding non-params source parameter), so the previous emit was already legal C# for them and the new emit produces a byte-identical signature. The PublicAPI snapshots therefore do not change.The CS0231 trigger only fires when a source method has at least one non-params source parameter sitting before a
paramssource parameter, which production assertion sources do not currently use. The fix is verified against six edge cases inTestData/ParamsParameterAssertion.cs.Out of scope
A separate but related shape exists in
AssertionExtensionGenerator.cs:407-424: it silently drops theparamsmodifier, producing an ergonomic regression rather than CS0231 for[AssertionExtension]classes whose constructor takesparams. Not in scope here; will file a separate issue if the direction in this PR is welcome.Related Issue
Fixes #5939
Type of Change
Checklist
Required
TUnit-Specific Requirements
TUnit.Core.SourceGenerator)TUnit.Engine)TUnit.Core.SourceGenerator.Testsand/orTUnit.PublicAPItests.received.txtfiles and accepted them as.verified.txt.verified.txtfiles[DynamicallyAccessedMembers]annotationsdotnet publish -p:PublishAot=trueTesting
dotnet test)Additional Notes
Regression tests added
A single test
MethodAssertionGeneratorTests.ParamsParameterexercises the newTestData/ParamsParameterAssertion.csfixture, which contains six[GenerateAssertion]shapes:params(the canonical CS0231 trigger).params.params.paramsarray.[GenerateAssertion(InlineMethodBody = true)]to cover the inline emit branch.params-only source method (no preceding non-params parameter) to pin the byte-identical regression baseline used byIsIn/IsNotIn.The test asserts the exact emitted signature for each shape with inline
.Contains(...)substrings, then callsCompileChecker.AssertNoErrorsto parse the generator output as C# and fail on any error diagnostic. That structural gate is the regression sentinel for CS0231 and any other emit defect that a content-only check cannot see.Snapshots are committed for net472 / net8.0 / net9.0 / net10.0; the generator emit is TFM-agnostic and the four files are byte-identical.
Local verification
TUnit.Assertions.SourceGenerator.Tests: 66 / 66 pass on net10.0, 65 / 65 on net472 (RefStructParameteris gated#if NET8_0_OR_GREATER).TUnit.Assertions.Tests: 2106 / 2106 pass on net10.0.