Skip to content

fix(generator): place CallerArgumentExpression before params in [GenerateAssertion] emit#5940

Merged
thomhurst merged 2 commits into
thomhurst:mainfrom
JohnVerheij:fix/method-assertion-params-callerargumentexpression-order
May 17, 2026
Merged

fix(generator): place CallerArgumentExpression before params in [GenerateAssertion] emit#5940
thomhurst merged 2 commits into
thomhurst:mainfrom
JohnVerheij:fix/method-assertion-params-callerargumentexpression-order

Conversation

@JohnVerheij
Copy link
Copy Markdown
Contributor

Description

Reorders the [GenerateAssertion] source generator emit so the auto-supplied [CallerArgumentExpression] parameters are placed BEFORE any params parameter in the generated extension method signature. The previous emit appended them AFTER the params array, which violates C#'s "params must be the last parameter" rule and produced error CS0231 for any source method shaped like (T first, params T[] rest).

Implementation

MethodAssertionGenerator.GenerateExtensionMethod previously walked data.AdditionalParameters twice: once to emit the source parameters (including the params modifier in place), then once to emit [CallerArgumentExpression] diagnostic parameters at the end (skipping the params parameter itself). The skip stopped a CallerArgumentExpression from landing ON the params parameter, 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:

  1. Emit each non-params source parameter, preserving default values.
  2. Emit [CallerArgumentExpression(nameof(...))] for each non-params source parameter.
  3. Emit the params source 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 / IsNotIn in GenericAssertions.cs are the only production [GenerateAssertion] methods that use params. Both have 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. 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 params source parameter, which production assertion sources do not currently use. The fix is verified against six edge cases in TestData/ParamsParameterAssertion.cs.

Out of scope

A separate but related shape exists in AssertionExtensionGenerator.cs:407-424: it silently drops the params modifier, producing an ergonomic regression rather than CS0231 for [AssertionExtension] classes whose constructor takes params. Not in scope here; will file a separate issue if the direction in this PR is welcome.

Related Issue

Fixes #5939

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Checklist

Required

  • I have read the Contributing Guidelines
  • If this is a new feature, I started a discussion first and received agreement
  • My code follows the project's code style (modern C# syntax, proper naming conventions)
  • I have written tests that prove my fix is effective or my feature works

TUnit-Specific Requirements

  • Dual-Mode Implementation: If this change affects test discovery/execution, I have implemented it in BOTH:
    • Source Generator path (TUnit.Core.SourceGenerator)
    • Reflection path (TUnit.Engine)
  • Snapshot Tests: If I changed source generator output or public APIs:
    • I ran TUnit.Core.SourceGenerator.Tests and/or TUnit.PublicAPI tests
    • I reviewed the .received.txt files and accepted them as .verified.txt
    • I committed the updated .verified.txt files
  • Performance: If this change affects hot paths (test discovery, execution, assertions):
    • I minimized allocations and avoided LINQ in hot paths
    • I cached reflection results where appropriate
  • AOT Compatibility: If this change uses reflection:
    • I added appropriate [DynamicallyAccessedMembers] annotations
    • I verified the change works with dotnet publish -p:PublishAot=true

Testing

  • All existing tests pass (dotnet test)
  • I have added tests that cover my changes
  • I have tested both source-generated and reflection modes (if applicable)

Additional Notes

Regression tests added

A single test MethodAssertionGeneratorTests.ParamsParameter exercises the new TestData/ParamsParameterAssertion.cs fixture, which contains six [GenerateAssertion] shapes:

  1. One required parameter followed by params (the canonical CS0231 trigger).
  2. Two required parameters followed by params.
  3. An optional defaulted parameter followed by params.
  4. A generic-typed required parameter alongside a generic-typed params array.
  5. The same canonical shape with [GenerateAssertion(InlineMethodBody = true)] to cover the inline emit branch.
  6. A params-only source method (no preceding non-params parameter) to pin the byte-identical regression baseline used by IsIn / IsNotIn.

The test asserts the exact emitted signature for each shape with inline .Contains(...) substrings, then calls CompileChecker.AssertNoErrors to 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 (RefStructParameter is gated #if NET8_0_OR_GREATER).
  • TUnit.Assertions.Tests: 2106 / 2106 pass on net10.0.
  • Build clean on net8.0 / net9.0 / net10.0 / netstandard2.0.

…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.
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 16, 2026

Not up to standards ⛔

🔴 Issues 1 minor

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
CodeStyle 1 minor

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

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.
@JohnVerheij
Copy link
Copy Markdown
Contributor Author

Addressed in ef76e33.

  • MEDIUM (line 34, fallback != null on unconstrained T): replaced with
    EqualityComparer<T>.Default.Equals(fallback, value). The body of
    IsOneOfWithDefault is test-fixture filler and the snapshot is byte-identical
    (not marked InlineMethodBody, so the body isn't reproduced in the generated
    extension method). CompileChecker and the six edge-case assertions stay green.

  • MINOR (line 12, "partial is gratuitous"): left as-is. The source generator
    emits the second partial half of this class; without partial on the test-data
    side we get CS0260. Same pattern in every other TestData file in the directory.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

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:

  1. Emit non-params source parameters (with their default values).
  2. Emit their [CallerArgumentExpression] counterparts.
  3. Emit the params parameter 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.

@thomhurst thomhurst enabled auto-merge (squash) May 17, 2026 14:03
@thomhurst thomhurst merged commit 30c57bd into thomhurst:main May 17, 2026
9 of 11 checks passed
@claude claude Bot mentioned this pull request May 18, 2026
1 task
github-actions Bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request May 19, 2026
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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.44.0&new-version=1.45.8)](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>
This was referenced May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: [GenerateAssertion] emits CallerArgumentExpression after params, producing CS0231

2 participants