Skip to content

Fix TUnit0014 on interface implementations, Aspire exit-code culture and [AssertionFrom] expectation messages - #7135

Merged
thomhurst merged 70 commits into
thomhurst:mainfrom
Quattordici:fix/assertionfrom-expectation-messages
Oct 20, 2026
Merged

thomhurst merged 70 commits into
thomhurst:mainfrom
Quattordici:fix/assertionfrom-expectation-messages

Conversation

@Quattordici

@Quattordici Quattordici commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

This PR holds the production fixes split out of #7129. It is stacked on #7129, so until that PR merges this one also shows its commits; the change itself is the last commit, fix: reapply the analyzer, Aspire and [AssertionFrom] fixes split out of #7129. Once #7129 merges I'll merge main in so only that commit remains.

Before: TUnit0014 flagged public methods that implement an interface member. Aspire printed out-of-memory exit codes with the current culture, so sv-SE showed a U+2212 minus sign. [AssertionFrom] custom ExpectationMessage values rendered without "to" (Expected be null or empty), {param} placeholders were not substituted, attributes that shared a method lost all but the first message, and overloads that mapped to the same generated class name all used the first overload's class.

After: TUnit0014 skips interface implementations. Exit codes are formatted with the invariant culture. Custom expectation messages read "to …" / "to not …" with NegateLogic, {param} placeholders (including non-ASCII names) are substituted, {{ / }} print literal braces, each attribute keeps its own message, and each overload gets its own condition class. [AssertionFrom] diagnostics are reported at the attribute.

How: AssertionMethodGenerator renders message text in one tokenizer pass, decides the "to"/"not" wording in one place, and resolves generated class names at emit time through per-overload references, so colliding names get numbered classes instead of being dropped. The attribute location is only computed when a diagnostic is reported, which keeps incremental caching intact.

Release note: [AssertionFrom] ExpectationMessage is now written without the leading "to"; the generator adds "to", and "not" with NegateLogic. Messages that already start with "to " or "to not " render as before.

Related Issue

Split out of #7129.

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: not applicable, no discovery or execution changes
  • Snapshot Tests: If I changed source generator output or public APIs:
    • I ran TUnit.Assertions.SourceGenerator.Tests and TUnit.PublicAPI tests
    • I reviewed the .received.txt files and accepted them as .verified.txt
    • I committed the updated .verified.txt files
  • Performance: generator work only; the attribute location is computed lazily
  • AOT Compatibility: not applicable, no reflection changes

Testing

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

Additional Notes

The review points from the github-actions reviews on #7129 about the generator are addressed here: tests for the message forms (ExpectationMessageRenderingTests, BuiltInExpectationMessageTests), a guard that no class-name marker reaches the output, numbered classes for colliding overloads (TU0002 is gone), and the lazy attribute location.

Summary by CodeRabbit

  • Enhancements

    • Improved assertion expectation messages with consistent “to”/“not” wording.
    • Added support for parameter placeholders, escaped braces, literal text, and clearer negation handling in custom expectation messages.
    • Improved generated assertion naming and diagnostic locations for clearer build feedback.
  • Bug Fixes

    • Interface implementations are no longer incorrectly flagged as missing test attributes.
    • Exit-code and diagnostic formatting is now consistent across cultures.
    • Removed generated span emptiness assertions; compatibility types now direct users to the supported memory assertion APIs.
  • Documentation

    • Expanded guidance for custom expectation message formatting and placeholders.

ReferencesHelper builds the compilation's references from the assemblies
already loaded in the test process. System.Net.Primitives (home of
HttpStatusCode on .NET) is not loaded by a plain run of the test exe, but
is loaded by server-mode hosts (`dotnet test`, Rider, VS). The .NET
snapshots were captured with HttpStatusCode unresolved (bare type name,
spurious `value is null` checks), so the test failed whenever it ran from
an IDE or `dotnet test`. Reference the assembly explicitly and update the
net8/9/10 snapshots; they now match the existing Net4_7 snapshot.
TUnit.Engine.Tests launches TUnit.TestProject, its F#/VB.NET siblings and
TUnit.TestProject.HtmlReportDefaults from hard-coded bin/Release folders.
CI builds everything in Release, but an IDE builds Debug and does not build
TestProject/F#/VB at all (they are not in TUnit.Dev.slnx), so locally the
suite failed with missing executables or ran stale Release binaries.

Add build-only project references for the launched apps and resolve their
output folder from this assembly's own configuration. CI is unchanged
(Release/Release); a Debug run from Rider/VS/dotnet test now builds what it
launches and passes without manual Release builds.
The project was an xUnit v2 VSTest project without a runner adapter, while
global.json makes `dotnet test` Microsoft.Testing.Platform-only. `dotnet
test` therefore refused it ("All projects must use that test runner"),
which also aborted solution-wide runs of TUnit.slnx, and its tests only ran
under an IDE's bundled xUnit runner.

Move it to xunit.v3 with the MTP runner (already pinned centrally). No test
code changes; all 64 tests pass both via `dotnet test` and as a plain
executable.
RpcTests and the Engine F#/VB/HTML-report tests start `dotnet build` /
`dotnet run` via a bare "dotnet", i.e. whatever is first on PATH. An IDE
does not necessarily share that PATH with the installation it builds and
runs tests with (notably macOS apps started from the Dock), and that
dotnet can lack an SDK satisfying global.json: the child then exits with
155 (SdkResolveFailure) and the tests fail with no explanation.

Resolve the muxer from DOTNET_HOST_PATH, else from the runtime running
the test process, falling back to PATH. Include the build output in the
RpcTests pre-build failure message.
These tests compare against invariant spellings ("1.1", "1.5", "-1",
"[-5]") of values formatted with the current culture, so they failed on
machines with e.g. sv-SE (decimal comma, U+2212 MINUS SIGN). In
TUnit.TestProject this also failed TUnit.Engine.Tests' ExpectedStateTests,
which requires every EngineTest=Pass test to pass.

Mark them [InvariantCulture]; the product's culture-aware formatting is
unchanged.
Exit codes are identifiers people search for (e.g. -532462766 /
0xE0434352, the .NET unhandled exception code). Under cultures such as
sv-SE the diagnostics printed negative codes with U+2212 MINUS SIGN,
which also failed FormatExitCode_KnownCode_IncludesNumberAndMeaning.
An earlier commit in this branch marked GenericArgumentsTest [InvariantCulture], but
TUnit.Core.SourceGenerator.Tests snapshots the generator output for this
file and the generator emits the test's attributes (and line numbers), so
the snapshot no longer matched. Format the value invariantly in the
method body instead, which leaves the generated metadata unchanged.
Both projects are TUnit-only (no xUnit tests or usings) but referenced
xunit and xunit.runner.visualstudio, which also put a VSTest adapter into
these Microsoft.Testing.Platform test apps.
…ning

Replace the [InvariantCulture] pins added earlier in this branch, which only hid the
tests' implicit en-US assumption, with expectations that hold in any
culture:

- CollectionAssertionTests: TUnit formats values in failure messages
  with the current culture, so build the expected "[-5]" the same way
  and keep testing what a user in that culture sees.
- AsyncTests: compare against the message formatted like the throwing
  factory formats it.
- WrapMultiParam fixture: culture is incidental, format invariantly.

Verified under en-US, sv-SE, de-DE, tr-TR and ar-SA.
The last non-TUnit test project in the repo. It now uses the shared
eng/TestProject.props/.targets setup like every other test project, so
it runs on the in-repo engine and analyzers.

Attributes were converted with TUnit's own TUXU0001 code fixer; the
assertions (which the fixer left as Xunit.Assert in these synchronous
methods) were converted to awaited TUnit assertions, with the test
methods and assertion helpers made async. Built with warnings as errors
to rule out un-awaited helpers; a deliberately broken shared helper
fails 8 tests, so assertions propagate. All 64 tests pass via `dotnet
test` and as a plain executable. Drops an unused helper.
The project's 64 generator-caching tests were in TUnit.slnx only, so CI
neither built nor ran them. Add it to TUnit.CI.slnx and a pipeline
module that runs it like the other test projects (Release, net10.0).
AssertionMethodGenerator rendered a custom ExpectationMessage as
`{(negated ? "not " : "")} {message}`, so every [AssertionFrom]
assertion with a custom message (e.g. string.IsNullOrEmpty, char
IsUpper, Task IsCompleted; their messages omit the leading "to" by
convention) failed with "Expected  be null or empty" (double space, no
"to"), and negated ones with "Expected not  be ...".

Emit `to {(negated ? "not " : "")}{message}` instead, matching the
"to not ..." wording used elsewhere. Snapshots updated (only that line
changes); Net4_7 snapshots received the identical substitution as they
cannot be regenerated off Windows. Adds a message regression test.
- RangeAssertionTests: HasEndFromBeginning and IsFullRange (successor of
  the obsolete IsAll) had no active tests, only commented-out ones
  saying the generator did not produce them yet.
- AssertionGroupTests: the And/Or grouping tests over IsNullOrEmpty were
  commented out as "not available"; reviving them exposed the message
  bug fixed in the previous commit.
- Old/StringAssertionTests: drop commented-out IsNullOrEmpty /
  IsNullOrWhiteSpace tests that StringNullabilityAssertionTests already
  covers, and their stale "not available" note.
…lation

DesignTimeProjectReferenceTests started `dotnet build` via a bare
"dotnet" from PATH, like the RpcTests/Engine tests fixed earlier in this branch.
Use the shared DotNetCli resolver so it uses the same installation.
TUnit0014 ("public method missing [Test]; add the attribute or make it
private") special-cased Dispose/DisposeAsync/InitializeAsync by name but
not interface implementations in general, so a test class implementing
e.g. ITestStartEventReceiver got a warning on OnTestStart that it could
not follow without breaking the interface (seen in
TUnit.UnitTests/PlaywrightCompositionLifecycleTests).

Skip methods that implement an interface member of the containing type.
A same-named public method that does not implement the member is still
reported.
…hots

- TUnit.Assertions.SourceGenerator.Tests (70 snapshot tests per TFM) had
  no pipeline module, so CI never ran it; that is how a wrong
  HttpStatusCode snapshot and the "Expected  be ..." message bug went
  unnoticed. Add a module mirroring RunSourceGeneratorTestsModule.
- RunSourceGeneratorTestsModule says to stay aligned with the project's
  TargetFrameworks but skipped net9.0, leaving the DotNet9_0 snapshots
  unchecked. Add it.
Five generator test projects built their in-memory compilations from
AppDomain.CurrentDomain.GetAssemblies(), i.e. whatever the process had
loaded so far. That set depends on how the tests are launched (IDE and
`dotnet test` server-mode hosts load assemblies a plain run does not),
which is how HttpStatusCode resolved in one mode and not the other
(see the HttpStatusCode snapshot fix earlier in this branch). The Should tests' comment even assumed it reflected
TRUSTED_PLATFORM_ASSEMBLIES; it does not.

Add a shared TestHostReferences helper: on .NET it returns the
TRUSTED_PLATFORM_ASSEMBLIES list (framework + deps.json, fixed by the
build); .NET Framework has no such list and keeps the loaded assemblies,
so the Windows-only Net4_7 snapshots are unaffected. Drops the now
redundant explicit HttpStatusCode reference. No snapshot changes; all
five projects pass on net8/9/10 via `dotnet test` and as plain runs.
Most test modules ran net10.0 only (TestBaseModule's default), Mocks.Http,
Mocks.Logging and AspNetCore.Tests ran net10.0+net8.0, and the five
analyzer modules ran a single hard-coded TFM via `dotnet test` (net8.0,
or net9.0 for AspNetCore analyzers), although their projects build
net8.0/net9.0/net10.0 and some contain TFM-specific tests (e.g.
TUnit.Assertions.Tests has a different test set on net8.0).

- TestBaseModule now defaults to net10.0, net9.0 and net8.0; frameworks a
  project did not build are already skipped, so net10.0-only projects
  still run once.
- Drop the net10.0+net8.0 overrides.
- Move the analyzer modules onto TestBaseModule, keeping their hang dump
  options with a per-framework dump file name.
…p dead files

- tests/TUnit.Core.Tests and tests/TUnit.IntegrationTests held test files
  but no project, so they were never compiled or run (the former used
  NUnit's Assert.That(x, Is.EqualTo(...)) and an invalid `var` tuple with
  a null element). ArgumentFormatter had no other tests: port its 9 tests
  to TUnit.UnitTests, with expectations matching current output (bools
  render as ToString(), "True"; numbers in the current culture). Drop
  CleanArchitectureTests, whose tests waited 100 ms for another test to
  have run.
- tests/Snapshots/TUnit._.verified held two BOM-only std-stream files
  that no test produces.
…ject setup

Both hand-rolled a subset of eng/TestProject.props/.targets (engine
props, engine/assertions/generator/analyzer references) and so lacked
the assertion analyzers every other test project gets, e.g. the one that
flags an un-awaited Assert.That, which silently checks nothing. Import
the shared props/targets like the other test projects. No new
diagnostics; all tests pass via `dotnet test` and as plain runs.
The project's RootNamespace and namespaces were
TUnit.Assertions.SourceGenerator.IncrementalTests although it covers the
core generators too (hooks, infrastructure, dynamic tests, property
injection, ...), which made it show up under a misleading node in test
explorers. Use the project name.
TUnit.Dev.slnx says it leaves out only the Roslyn version variants, but
it also omitted the Should library and its tests, the Mocks
InternalsAccess task and tests, SourceGenerator.IncrementalTests,
AspNetCore.Tests and Templates.Tests, so "run all tests" from the dev
solution never ran them. Add them with the projects they need (each runs
in seconds without external services). Aspire.Tests (needs Docker, >1
min) and the template's Playwright content project stay out.
PublicizeAssemblyReferencesTaskTests uses Mono.Cecil directly but only
received it transitively through the task project reference. An IDE
build whose restore did not carry the transitive package (seen in Rider
on macOS) failed with CS0103 "The name 'Mono' does not exist". Declare
the dependency the code actually has.
…very OS

The trusted-platform-assemblies list has no defined order and differs between
Linux and macOS hosts. Generators emit referenced assemblies in reference order,
so DuplicateTypeNameAcrossAssembliesTests' snapshot listed assemblies in a
different order on macOS. Sort the paths by file name to make compilations
identical on every host.
The scrubber only handled CI's deterministic /_/ source paths. A local checkout
path longer than 80 characters is wrapped by the API generator into "..." +
continuation lines, so OpenTelemetry_Library_Has_No_API_Changes kept a fragment
of the path on macOS. Replace whole path literals, including continuations, for
Unix and Windows paths.
…on and path scrubbing

- AssertionMethodGenerator: accept [AssertionFrom] expectation messages that
  already start with "to " (the documented style) so they no longer render
  as "to to ...", and leave a spelled-out "to not ..." alone instead of
  negating it twice. Built-in messages, which omit the "to", are unchanged.
- DotNetCli: only prefer the running installation's muxer when it has an
  SDK; a runtime-only installation cannot run `dotnet build`, so fall back
  to `dotnet` on PATH.
- PublicAPI: only scrub rooted ".cs" string literals followed by the
  caller line number argument, so public values such as "/v1" are kept,
  and cover the scrubber with focused tests (short CI paths, wrapped Unix
  and Windows paths, unrelated strings).
…essages

A custom ExpectationMessage was emitted verbatim into GetExpectation()'s
interpolated string, but the generated class stores method parameters
as `_{name}` fields, so the documented `"to start with {value}"` failed
to compile with CS0103. Rewrite `{name}` to `{_name}` for each parameter,
as [GenerateAssertion] already does.

Quote the placeholders in the StartsWith/EndsWith examples so the shown
failure messages ('he') match what the examples produce.
Placeholders were substituted with one string.Replace per parameter, so
the escaped "{{value}}" also became "{_value}" and printed the generated
field name. Tokenize the message once instead: "{name}" for a parameter
becomes the "{_name}" field, and everything else stays literal text,
including escaped braces, placeholders that name no parameter, stray
braces, quotes and backslashes, which previously produced code that did
not compile.

Decide the "to"/"to not" wording in one helper for custom and default
messages; a negated default now reads "to not satisfy X" rather than
"not to satisfy X", matching the custom messages.

Document how ExpectationMessage is rendered on both attributes and in the
docs, and guard every built-in [AssertionFrom] declaration, including the
files without a snapshot test, against "to to" and "not not".
Condition classes that shared a name were numbered in the order they
were emitted, via in-band markers patched with string.Replace. Adding or
removing an attribute could change which class got a number, which
changes public generated type names.

Overloads that map to the same name are now told apart when the
attribute is rendered: the first by parameter count and signature keeps
the plain name, and the others get a stable hash of their signature.
Attributes on one method with different sources, such as different
expectation messages, keep the plain name for the source that sorts
first and a hash of their source for the rest, renamed token by token.
Names now depend only on the declarations, not on order.
@Quattordici

Copy link
Copy Markdown
Contributor Author

Review of #7135

Overall this is a well-scoped set of fixes with good documentation. I read the production changes in the analyzer, Aspire and [AssertionFrom] generator. I did not build the PR or run any tests.

What looks good

  • TUnit0014: ImplementsInterfaceMember uses FindImplementationForInterfaceMember against the containing type's AllInterfaces. That is the right API, and it correctly covers implicit implementations.
  • Aspire: every exit-code format site now goes through FormatExitCodeNumber with the invariant culture. The OutOfMemory branch also handles a null exit code.
  • Generator: the single-pass tokenizer (RenderMessageText) is much safer than the old string interpolation, which did not escape quotes or braces. SymbolDisplay.FormatLiteral handles quotes and backslashes. Keeping non-ASCII parameter names working through a broad [^{}]* match is a nice call.
  • Diagnostics: only the SyntaxReference flows through the pipeline, and the location is computed only when a diagnostic is reported. DiagnosticInfo is now shared instead of nested, which keeps incremental caching intact.

Concerns and suggestions

  1. In-band string markers (\u0001/\u0002) for class-name resolution. The approach works, and the comment says an unresolved marker fails the build loudly. But pre-rendering strings and then patching them with string.Replace is fragile: any new place that emits a class name must remember to use ClassNameReference. A more robust design is to keep the rendered snippets as small structured parts (a literal / class-ref sequence) and resolve names at emit time. Alternatively, derive a unique class name deterministically at transform time, for example by appending a short hash of the overload signature when a method has more than one overload mapping to the same name. That would remove the global emit-time dedup state (GeneratedConditionClasses) and make the generated names stable across edits. With the current approach, adding or removing an unrelated attribute can change which attribute gets the 2 suffix, and so change public generated type names.
  2. Numbered-name collisions. Claim adds suffixes with _names.Add in a loop. That is fine for ordering within one run, but the suffix depends on attribute iteration order. Please make sure that order is deterministic (sorted by something stable), or snapshot output can flap.
  3. "not " prefix handling in RenderCustomExpectation. A message starting with "not " is emitted as to not … and ignores _negated. If such an attribute is used without NegateLogic, or the negation is toggled at runtime, the wording stays fixed. This is an edge case, but a test for it would pin down the intended behaviour. Also, the heuristic strips a leading "to " from any message, so a legitimate phrase like "to-do…" is unaffected but "to " followed by prose is silently altered. That is acceptable given the release note.
  4. Scope. The PR is stacked on Make the test suite pass in every environment and run fully in CI #7129 and also contains .slnx additions, test-helper changes and new test projects (e.g. TUnit.SourceGenerator.IncrementalTests). Once Make the test suite pass in every environment and run fully in CI #7129 merges, please verify that the diff really is the single intended commit. The generator snapshot tests and tests/TUnit.PublicAPI should be run, since the attribute docs and generated output changed.
  5. Behaviour change. The release note on ExpectationMessage is good. Consider also calling it out in docs/.../source-generator-assertions.md (already touched) with a before/after example, so existing users who wrote "to be …" know both forms still work.

No blocking issues found. The main suggestion is #1, moving toward structured or deterministic naming instead of marker substitution.

🤖 Generated with Claude Code

Agreed, the order dependence was the real problem. 8d7b295 removes the markers, string.Replace and the order-based numbering:

  • Overloads sharing a name are named when the attribute is rendered: the first by parameter count, then signature, keeps the plain name, and the others get a stable hash of their signature. That depends only on the method's own overloads.
  • Attributes on one method with different messages (possibly in different classes) are resolved at emit time as a pure function of the set: the source that sorts first keeps the plain name, the others get a hash of their source, renamed token by token.

TUnit's own output and the PublicAPI snapshots are unchanged. ConditionClassNamingTests pins that names don't depend on declaration order or unrelated attributes.

The generated source is built with AppendLine, so it carries the host's
line endings, and the hashed names of attributes that share a method
differed between Windows and Linux. Normalize to \n before sorting and
hashing, and pin the hashed names in a test that runs on every host.
@github-actions

Copy link
Copy Markdown
Contributor

Review of #7135

I read the production changes. The most important part is AssertionMethodGenerator. I did not build the project or run the tests, and I only skimmed the analyzer and Aspire diffs. The /code-review skill failed to run, so this is a manual review.

What looks good

  • Class names no longer depend on declaration or emit order. They come from a sort by source plus an FNV-1a hash, and string.GetHashCode is not used. Line endings are normalized before hashing, so names do not vary by host.
  • RenderMessageText handles placeholders, {{ }}, stray braces and non-ASCII parameter names in one regex pass. This replaces the earlier ad hoc string replacement.
  • The "to" / "not" wording is decided in one place, RenderNegatableExpectation.
  • The SyntaxReference is turned into a Location only when a diagnostic is actually reported. The DiagnosticInfo model and the comment about never caching AttributeSyntax keep incremental caching intact.
  • Diagnostics now carry the attribute location instead of Location.None, so #pragma and .editorconfig severities apply.

Suggestions / risks

  1. Backward-compatible prefix stripping. RenderCustomExpectation strips a leading "to " and special-cases "not ". That is a sensible compatibility shim, but a message like "to" or "not equal" written in the new style is treated as already negated. Under NegateLogic that renders to not equal, which is probably correct, but without NegateLogic it never gets the negation toggle. I suggest a unit test for a positive message that starts with "not " (for example, "not be empty" as the non-negated form), or a note in the docs.
  2. RenameIdentifier reparses source with SyntaxFactory.ParseTokens. It runs only on collisions, so the cost is fine. It does work on already-rendered strings inside the emit step, which is fragile. Longer term, carrying the class name as a placeholder token in the rendered source and substituting once at emit time would remove the reparse and the second naming pass (GetConditionClassName and AssignConditionClassNames).
  3. Two-stage naming is hard to reason about. GetConditionClassName hashes overloads per method, and AssignConditionClassNames then hashes colliding variants globally. The salt loop terminates, but a short comment or a test showing a case that hits both layers would help future maintainers.
  4. Release note. The ExpectationMessage semantics change (the generator now adds "to"). The compatibility path covers messages that already start with "to ". Other custom messages will read differently, so please make sure the release notes call this out.

No blocking issues found. Overall this is a solid fix, with good test coverage listed in the diff (naming, message rendering, incremental, and snapshot tests).

Pins that the two naming steps compose: overloads are told apart when
the attribute is rendered, and attributes with different messages for
each overload at emit time. The two steps now point at each other.
@github-actions

Copy link
Copy Markdown
Contributor

Review of PR 7135

I reviewed the diff directly, because the code-review skill failed to run. I didn't build the project or run any tests.

Overall: The fixes are well-reasoned, and the tests and snapshots cover each one. The changes are TUnit0014 skipping interface implementations, Aspire formatting exit codes with the invariant culture, [AssertionFrom] messages rendered in a single tokenizer pass, per-overload condition classes, and attribute-located diagnostics computed lazily so incremental caching still works.

Concerns

  1. Hash-suffixed class names depend on the generated source text. AssignConditionClassNames derives the hash for non-first variants from the generated source. Editing an ExpectationMessage therefore renames the generated class, so names are stable across builds but not across edits. The classes look like internal helpers, so this is probably fine. If anything public or user-visible references them (stack traces, downstream snapshot tests), the churn will show. Please confirm nothing public exposes them. If something does, derive the hash from the attribute's identity (target method signature plus an attribute index) instead of the message text.

  2. Naming is split across two passes. GetConditionClassName handles overloads and AssignConditionClassNames handles attributes, each with its own collision logic and WithHash call. The doc comments explain the split well. Still, a single resolver at emit time, keyed by (method symbol, attribute), would be easier to reason about than two passes that both have to stay order-independent.

  3. Leading 'to ' and 'not ' are handled by prefix sniffing. RenderCustomExpectation strips a leading to and treats a leading not as already negated. A message such as 'not found' without NegateLogic therefore renders as 'to not found' and ignores negation. Apply the 'not ' special case only when NegateLogic is set. If the current behaviour must stay, add a test that pins it.

  4. Release note. Dropping the leading 'to' is a user-visible behaviour change. The PR description calls it out. Please also make sure it lands in the docs page and the release notes.

  5. Minor. MessageTokenRegex is a static field declared mid-class, so move it up with the other fields. Consider RegexOptions.CultureInvariant, in line with the rest of this PR's culture handling.

Positives

  • The analyzer fix uses FindImplementationForInterfaceMember, the correct way to detect implicit implementations.
  • CultureInfo.InvariantCulture for the Aspire exit codes fixes the sv-SE minus sign.
  • Computing the diagnostic location lazily keeps the incremental pipeline's cache hits intact.

No blocking issues from my read. Item 3 is the one I'd resolve or pin with a test before merge.

…xpectation-messages

# Conflicts:
#	src/TUnit.Assertions.SourceGenerator/Generators/AssertionMethodGenerator.cs
#	src/TUnit.Assertions/Conditions/SpanAssertionExtensions.cs
#	tests/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt
#	tests/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt
#	tests/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@github-actions

Copy link
Copy Markdown
Contributor

Review: PR 7135 (analyzer, Aspire and [AssertionFrom] fixes)

I read the source changes and found nothing blocking. I did not build the PR or run its tests. I could not run the automated code-review skill, so this review is a manual read of the diff.

What looks good

  • TUnit0014: ImplementsInterfaceMember uses FindImplementationForInterfaceMember over AllInterfaces, so it handles implicit implementations correctly, including inherited interfaces.
  • Aspire: Formatting exit codes with the invariant culture is correct. Every place that prints one now goes through FormatExitCodeNumber. The ExitCode is null branch in DescribeState is a small extra fix.
  • Message rendering: RenderMessageText handles {param}, {{/}}, unknown placeholders, stray braces, and quotes and backslashes (via FormatLiteral) in one tokenizer pass. RenderNegatableExpectation is now the only place that decides the "to"/"to not" wording.
  • Compatibility: Accepting messages that already start with to or to not keeps existing usages rendering as before, and the docs describe this.
  • Class naming: Names no longer depend on declaration or emit order. Overload names use a signature hash, member collisions get a namespace prefix, and Claim re-hashes with a salt if a hash collides. That is sturdier than the earlier placeholder substitution. The hash is FNV-1a, not string.GetHashCode, so names stay stable across builds. The line endings are normalised before hashing.
  • TU0001 diagnostics: DiagnosticInfo holds only primitive location data. The location is computed only when a diagnostic is reported, and the diagnostic is reattached to the current compilation's syntax tree. That preserves incremental caching and lets #pragma and editorconfig severities apply.
  • Tests: The new tests cover the message forms, class-name collisions, a guard against leaked name markers, and incremental behaviour.

Non-blocking suggestions

  1. Repeated overload scan: GetConditionClassName scans every overload of the method each time it is called, and it is called from the condition-class path and the extension-method path. That is O(n²) per member for heavily overloaded APIs. If a type with many overloads ever shows up in generator timings, compute the names once per (containingType, methodName) and reuse them.
  2. Token-based rename: RenameIdentifier renames by token and arity. That is sound, but it re-parses the whole generated source for every renamed class. It only runs for collisions, so the cost is fine. A short comment pointing at the test that covers a same-named class of a different arity would help future maintainers.
  3. Breaking change: The release note covers the ExpectationMessage change. Consider also calling it out in the migration or changelog notes. A message such as "not null" was previously rendered as not not null when negated, and is now treated as an already-negated phrase, so NegateLogic is not applied to it.

Overall this is a clean, well-tested fix, and I would approve it.

… argument count

A renamed generic condition class also renamed references to a same-named
class of another non-zero arity, e.g. BoxCheckAssertion<T, U> when only
BoxCheckAssertion<T> collided, so the generated code did not compile.
@github-actions

Copy link
Copy Markdown
Contributor

Review of #7135

I read the diff for the analyzer, Aspire and [AssertionFrom] changes. I did not build the PR or run its tests, and the code-review skill failed to start, so this was a manual read. I found no blocking issues.

What looks good

  • TUnit0014: ImplementsInterfaceMember uses FindImplementationForInterfaceMember over AllInterfaces, which is the correct way to detect implicit implementations. Explicit implementations are not public methods, so they are not affected. The analyzer tests cover the case.
  • Aspire exit codes: Every place that formats an exit code now goes through FormatExitCodeNumber with the invariant culture. This includes the OOM branch, which also gets a sensible fallback when ExitCode is null.
  • Incremental caching:
    • Holding a SyntaxReference in the model and building the Location only when a diagnostic is reported avoids a per-edit cache miss.
    • DiagnosticInfo stores only primitives and re-attaches to the tree by path, falling back to a checksum when paths collide. That keeps #pragma and .editorconfig severity working without keeping old compilations alive.
  • Condition class naming: Resolving names at emit time per overload, and numbering collisions instead of dropping them, fixes the "all overloads share the first class" bug properly. The new naming tests cover it.
  • Message rendering: The single tokenizer pass handles {{/}}, placeholders and non-ASCII parameter names. It replaces the earlier ad-hoc string scanning.

Suggestions (non-blocking)

  1. SyntaxReference invariant: The remark on the model says the record must never cross a cache boundary. AssertionClassModel, which holds the SyntaxReference, is the transform output. That makes the invariant rely on the SyntaxReference being excluded from the model's equality, or on the incremental tests catching a regression. Please confirm the equality exclusion is explicit (for example [IgnoreDataMember]-style custom Equals, or storing the reference outside the compared members), and keep an incremental test that edits an unrelated file and asserts every step is Cached.
  2. Diagnostics re-run: diagnostics.Combine(CompilationProvider) re-runs on every compilation change whenever any TU0001 exists. Your doc comment already says so, and it is acceptable because it only applies in the error case. DiagnosticInfo.ToDiagnostic also hashes full tree text on ambiguous paths, which is rare but potentially costly. If it ever shows up, cache the checksum per tree.
  3. Regex in the generator: MessageTokenRegex is fine, but give it RegexOptions.CultureInvariant. A static tokenizer loop would also avoid the Regex dependency in the netstandard2.0 analyzer assembly.
  4. Release note: Dropping the leading "to" from ExpectationMessage is a behavior change. Existing messages that start with "to " or "to not " still render as before, which softens it. Please make sure the release note and docs example make the new convention explicit (the docs diff already touches this).

Overall this is a clean split-out with solid test coverage. Approving in spirit.

@github-actions

Copy link
Copy Markdown
Contributor

Review of #7135

I read the generator changes (AssertionMethodGenerator.cs) and skimmed the other source changes. I didn't build the PR or run any tests.

What looks good

  • Message rendering is now one tokenizer pass (RenderMessageText). It handles {{/}}, non-ASCII parameter names, stray braces and quote/backslash escaping, and unknown placeholders stay literal. This is better than the ad-hoc replacements it replaces.
  • The "to" / "to not" wording is decided in one place (RenderNegatableExpectation). Messages that already start with "to " still render as before, so the breaking change is softened.
  • Class names are stable across builds and don't depend on declaration order. They use FNV-1a rather than GetHashCode, and line endings are normalised in HostIndependent.
  • Computing the attribute location only when a diagnostic is reported keeps incremental caching intact.

Concerns and suggestions (non-blocking)

  1. Complexity of the naming scheme. Naming is split across GetConditionClassName (per overload) and AssignConditionClassNames (per attribute). RenameIdentifier re-parses the generated source with SyntaxFactory.ParseTokens to rename classes after the fact. A cleaner design would give each condition class a name key (member identity, arity, source hash) when the model is built, and emit with that name directly. Then no post-hoc token rewrite is needed. If that's too large a change here, please at least document the invariant that the rename only touches identifiers with matching arity.
  2. Public names derived from message text. A non-first variant gets a hash of its source, which includes the message. Editing an ExpectationMessage can therefore rename a generated public class when two attributes share a member. This only affects the colliding case, but a short note in the docs would help.
  3. "to " prefix heuristic. Stripping a leading "to " and treating a leading "not " specially is a silent behavioural rule. A message that legitimately starts with "to " (for example "to-do items…") is unaffected, but one like "to not …" can't be told apart from a caller who wants a literal "not". This is acceptable. Please make sure the release note and AssertionFromAttribute XML docs state the rule explicitly.
  4. Test coverage. The new tests (ExpectationMessageRenderingTests, ConditionClassNamingTests) are a good start. Please also cover generic overloads with different arity, and nested generic type arguments in TypeArgumentCount, since >> handling is subtle.
  5. The PR is stacked on Make the test suite pass in every environment and run fully in CI #7129, so the diff is large until that merges. Please re-check the snapshots after the main merge, so no .received.txt files slip in.

Overall this is a solid fix with good tests. My concerns are mostly about long-term maintainability of the naming machinery.

🤖 Generated with Claude Code

@thomhurst

Copy link
Copy Markdown
Owner

Thanks!

@thomhurst
thomhurst merged commit b0564f3 into thomhurst:main Oct 20, 2026
22 of 23 checks passed
thomhurst added a commit that referenced this pull request Oct 20, 2026
…72 expectation guard

- Generated data source factories and property metadata getters now escape member names that are C# keywords (e.g. @event), which previously produced uncompilable code.
- BuiltInExpectationMessageTests guard threshold lowered: net472 renders 95 expectations because #if NET declarations are not generated, failing Windows CI since #7135.
thomhurst added a commit that referenced this pull request Oct 20, 2026
net472 parses the [AssertionFrom] sources without the NET symbols, so their
#if NET declarations drop out and only 95 expectations render, failing the
> 100 guard on the Windows leg since #7135.
thomhurst added a commit that referenced this pull request Oct 20, 2026
The net472 test compilation has no NET preprocessor symbols and older BCL
references, so only 95 [AssertionFrom] expectations render there versus 121
on net10.0. The > 100 guard from #7135 therefore failed every Windows
modularpipeline job.
thomhurst added a commit that referenced this pull request Oct 20, 2026
… into their own workflow (#7226)

* ci: publish the AOT test app and run the AOT engine tests in their own workflow

Inside the .NET matrix the AOT test-app publish competed with the test
modules for CPUs and was the critical path on every OS (12-25 minutes),
with the Reflection-mode engine tests held back until it finished.

Co-Authored-By: Claude <noreply@anthropic.com>

* ci: gate NuGet publishing on the aot.yml run for the commit

UploadToNuGetModule now waits for an aot.yml run on the same SHA to pass
before pushing packages, and fails when none passed or none finishes within
60 minutes. The release job gains actions: read for the lookup. The AOT
workflow's concurrency group keys PR runs by PR number, so fork PRs that
share a branch name no longer cancel each other.

* test: lower the expectation-count guard below the net472 count

net472 parses the [AssertionFrom] sources without the NET symbols, so their
#if NET declarations drop out and only 95 expectations render, failing the
> 100 guard on the Windows leg since #7135.

* ci: move the NugetTester AOT publishes into the AOT workflow

They are three more native AOT publishes, and in the matrix they still
competed with the engine tests for CPUs. The AOT workflow now packs too.

Co-Authored-By: Claude <noreply@anthropic.com>

* ci: keep polling for an unlisted AOT run, lift the upload module timeout and pass GitVersion outputs through env

UploadToNuGetModule waits up to 60 minutes for aot.yml, but kept the default
30-minute module timeout, so a slow AOT run aborted the release before the
wait's own deadline. A run that is not listed yet now counts as pending, and a
missing GITHUB_REPOSITORY fails with a clear message.

aot.yml passes the GitVersion outputs to the export script through env, so a
branch name in InformationalVersion cannot reach the shell source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: stop the AOT version env vars colliding with GitVersion's on Windows

Windows environment names are case-insensitive, so the step's GITVERSION_SEMVER
merged with the GitVersion action's GitVersion_SemVer and bash saw only the
action's spelling. Version was exported empty, TUnit.Assertions.Should's
"$(Version)-beta" became invalid and restore failed on windows-latest.

Co-Authored-By: Claude <noreply@anthropic.com>

* ci: retry the AOT release gate's run listing on GitHub API errors

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
Pull Requests — 610e2fa9 Deployed Oct 20, 2026 by Quattordici via modularpipeline (macos-latest) #20356
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.

2 participants