Skip to content

Add CS8629 suppression and member access expression matching to IsNotNullAssertionSuppressor#5201

Merged
thomhurst merged 4 commits intomainfrom
copilot/fix-suppress-nullable-warning
Mar 21, 2026
Merged

Add CS8629 suppression and member access expression matching to IsNotNullAssertionSuppressor#5201
thomhurst merged 4 commits intomainfrom
copilot/fix-suppress-nullable-warning

Conversation

Copy link
Contributor

Copilot AI commented Mar 21, 2026

The IsNotNullAssertionSuppressor was missing CS8629 ("Nullable value type may be null") and couldn't match member access expressions passed to Assert.That().

var value = new { Id = id }; // id is int?
await Assert.That(value.Id).IsNotNull();
int x = value.Id.Value; // CS8629 not suppressed

Two root causes:

  • CS8629 not in supported diagnostics — only CS8600/CS8602/CS8604/CS8618 were handled
  • Expression matching limited to simple identifiersAssert.That(value.Id) couldn't match against value.Id in subsequent usage because GetIdentifierFromNode reduced everything to the root IdentifierNameSyntax

Changes

  • Add CS8629 to IsNullabilityWarning() and SupportedSuppressions
  • Replace GetIdentifierFromNodeGetTargetExpression returning ExpressionSyntax to preserve full member access chains
  • Add ExpressionsMatch with recursive semantic symbol comparison for member access chains, direct symbol comparison for identifiers
  • Add 4 tests: simple int?, negative case, anonymous type member access, named type member access
Original prompt

This section details on the original issue you should resolve

<issue_title>[Feature]: Suppress nullable warning</issue_title>
<issue_description>### Problem Statement

Hi,

Assert.That().IsNotNull() don't suppress nullable warnings for some code after the Assert.
The following code gives a warning CS8629 "Nullable value type may be null." when the Id is guaranteed to be not null after the Assert.

Proposed Solution

[Test]
[Arguments(1)]
public async Task CheckNull(int? id)
{
    var value = new
    {
        Id = id
    };

    await Assert.That(value.Id)
                .IsNotNull();

    await Assert.That(value.Id.Value)
                .EqualTo(0);
}

Alternatives Considered

For example with an extra if ... is not null, the compiler can suppress the warning :

[Test]
[Arguments(1)]
public async Task CheckNull(int? id)
{
    var value = new
    {
        Id = id
    };

    await Assert.That(value.Id)
                .IsNotNull();

    if (value.Id is not null)
    {
        await Assert.That(value.Id.Value)
                    .EqualTo(0);
    }
}

Feature Category

Other

How important is this feature to you?

Nice to have - would improve my experience

Additional Context

No response

Contribution

  • I'm willing to submit a pull request for this feature</issue_description>

<agent_instructions>Check the nullable code suppressor for not null assertions - any reason why it wouldn't always work?</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@thomhurst There is meant to be an analyzer to suppress the nullable warnings - maybe it's not always firing for some reason

📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.

Copilot AI and others added 2 commits March 21, 2026 11:40
…sion matching

- Add CS8629 ("Nullable value type may be null") to suppressed diagnostics
- Generalize identifier matching to handle member access expressions (e.g., value.Id)
- Use structural comparison for complex expressions and symbol comparison for identifiers
- Add tests for CS8629 suppression on simple and member access nullable value types

Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/022e5c99-c1cc-4b6a-83d9-4a5e89b4a374
…ed type test

- Use recursive semantic symbol comparison instead of structural comparison
- Filter fallback expression types to only identifiers and member access
- Add test case with named type to verify member access matching

Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/022e5c99-c1cc-4b6a-83d9-4a5e89b4a374
Copilot AI changed the title [WIP] Fix suppression of nullable warning in tests Add CS8629 suppression and member access expression matching to IsNotNullAssertionSuppressor Mar 21, 2026
Copilot AI requested a review from thomhurst March 21, 2026 11:49
Copilot AI temporarily deployed to Pull Requests March 21, 2026 11:49 Inactive
Copilot AI temporarily deployed to Pull Requests March 21, 2026 11:49 Inactive
@thomhurst thomhurst marked this pull request as ready for review March 21, 2026 11:54
@thomhurst thomhurst enabled auto-merge (squash) March 21, 2026 11:54
Copy link
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: Add CS8629 suppression and member access expression matching

Overall, this is a well-scoped fix that correctly addresses both root causes identified in the issue. The semantic symbol comparison approach in ExpressionsMatch is the right way to handle this.

What's Good

  • Semantic symbol comparison in ExpressionsMatch is correct and handles aliasing/renaming properly. Comparing by syntax text alone (e.g., string matching on value.Id) would be fragile; using SymbolEqualityComparer is the right approach.
  • Recursive member access chain comparison correctly distinguishes obj1.Id from obj2.Id by recursively comparing receivers — a simple member-symbol comparison would produce false positives here.
  • Test coverage is solid: simple nullable, negative case, anonymous type, named type — the four cases cover the meaningful scenarios for CS8629.

Concerns / Issues

1. GetTargetExpression fallback may return a sub-expression prematurely

_ => node.DescendantNodesAndSelf()
    .OfType<ExpressionSyntax>()
    .FirstOrDefault(e => e is IdentifierNameSyntax or MemberAccessExpressionSyntax)

DescendantNodesAndSelf() returns nodes in pre-order (root before children), so this should correctly return an outer MemberAccessExpressionSyntax before any inner IdentifierNameSyntax. However, if the node type is something unexpected (e.g., ConditionalAccessExpressionSyntax for value?.Id), this fallback will return the inner value identifier rather than the full value?.Id. This isn't a regression — the old code had the same limitation — but it's worth noting that ConditionalAccessExpressionSyntax is not handled at all. Consider adding it to both the switch and ExpressionsMatch as a future improvement.

2. WasAssertedNotNull only searches MethodDeclarationSyntax

var containingMethod = targetExpression.FirstAncestorOrSelf<MethodDeclarationSyntax>();

This means the suppressor won't work inside lambda expressions or local functions — both common in async test code. For example:

Func<Task> act = async () => {
    await Assert.That(value.Id).IsNotNull();
    int x = value.Id.Value; // NOT suppressed — lambda scope not found
};

A more robust approach would walk ancestors matching MethodDeclarationSyntax | LocalFunctionStatementSyntax | AnonymousFunctionExpressionSyntax and use the innermost one. This is pre-existing but worth addressing since test code often uses lambdas for ThrowsException wrappers.

3. Mixed-type ExpressionsMatch silently returns false

// For simple identifiers...
if (assertArgument is IdentifierNameSyntax && targetExpression is IdentifierNameSyntax) { ... }

// For member access chains...
if (assertArgument is MemberAccessExpressionSyntax && targetExpression is MemberAccessExpressionSyntax) { ... }

return false; // mixed types

This is semantically correct (e.g., asserting id should not suppress a warning on wrapper.Id). However, the silent return false for all other combinations makes the code harder to reason about. A brief comment explaining that mismatched types are intentionally not matched would improve readability.

4. ElementAccessExpressionSyntax not handled

Array/indexer patterns like Assert.That(arr[0]).IsNotNull() aren't handled, but this is out of scope for this PR.

Minor

SupportedSuppressions.FirstOrDefault(s => s.SuppressedDiagnosticId == diagnostic.Id) in Suppress() is an O(n) LINQ search on every suppression event. A static readonly Dictionary<string, SuppressionDescriptor> would be cleaner and O(1), though the list is only 5 entries so this is negligible in practice.

Summary

The implementation is correct and solves the reported issue. The concerns above are either pre-existing limitations or minor suggestions. Recommend merging after considering the lambda/local-function scoping issue (#2), which could be a meaningful gap in real test scenarios.

@thomhurst thomhurst disabled auto-merge March 21, 2026 12:07
- Support lambdas and local functions as containing scopes, not just methods
- Replace LINQ FirstOrDefault with manual loop to avoid allocations
- Share SuppressionDescriptor instances between dictionary and SupportedSuppressions
- Add clarifying comment on intentional mixed-type expression mismatch
- Add tests for lambda and local function scoping
Copy link
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.

Follow-up Review

This is a follow-up to the previous claude[bot] review. All actionable concerns have been addressed:

Previous Concerns — Status

✅ Concern #2 (Lambda/local function scoping) — Fixed. The WasAssertedNotNull method now walks ancestors matching MethodDeclarationSyntax | LocalFunctionStatementSyntax | AnonymousFunctionExpressionSyntax, so assertions inside lambdas and local functions are correctly found. Two new tests (Suppresses_Inside_Lambda, Suppresses_Inside_Local_Function) validate this.

✅ Concern #3 (Silent return false in ExpressionsMatch) — Fixed. A comment now explains the intentional behaviour: "Mismatched expression types are intentionally not matched — asserting id should not suppress warnings on wrapper.Id or vice versa."

✅ Minor (O(n) FirstOrDefault in Suppress) — Fixed. SuppressionsByDiagnosticId dictionary provides O(1) lookup and Descriptors array keeps SupportedSuppressions construction clean.

⚠️ Concern #1 (ConditionalAccessExpressionSyntax value?.Id) — Acknowledged as pre-existing; not addressed here, which is acceptable scope-wise.

⚠️ Concern #4 (ElementAccessExpressionSyntax for indexers) — Out of scope; still not handled, which is fine.

New Code Quality

The new tests are well-structured and cover all the meaningful scenarios. The recursive ExpressionsMatch + SymbolsMatch design is clean and semantically correct. No new issues introduced.

Verdict

All previously raised actionable issues have been resolved. The implementation is correct, well-tested, and ready to merge.

This was referenced Mar 23, 2026
github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Mar 23, 2026
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.19.57 to
1.21.6.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.21.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.6 -->

## What's Changed
### Other Changes
* perf: replace object locks with Lock type for efficient
synchronization by @​thomhurst in
thomhurst/TUnit#5219
* perf: parallelize test metadata collection for source-generated tests
by @​thomhurst in thomhurst/TUnit#5221
* perf: use GetOrAdd args overload to eliminate closure allocations in
event receivers by @​thomhurst in
thomhurst/TUnit#5222
* perf: self-contained TestEntry<T> with consolidated switch invokers
eliminates per-test JIT by @​thomhurst in
thomhurst/TUnit#5223
### Dependencies
* chore(deps): update tunit to 1.21.0 by @​thomhurst in
thomhurst/TUnit#5220


**Full Changelog**:
thomhurst/TUnit@v1.21.0...v1.21.6

## 1.21.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.0 -->

## What's Changed
### Other Changes
* perf: reduce ConcurrentDictionary closure allocations in hot paths by
@​thomhurst in thomhurst/TUnit#5210
* perf: reduce async state machine overhead in test execution pipeline
by @​thomhurst in thomhurst/TUnit#5214
* perf: reduce allocations in EventReceiverOrchestrator and
TestContextExtensions by @​thomhurst in
thomhurst/TUnit#5212
* perf: skip timeout machinery when no timeout configured by @​thomhurst
in thomhurst/TUnit#5211
* perf: reduce allocations and lock contention in ObjectTracker by
@​thomhurst in thomhurst/TUnit#5213
* Feat/numeric tolerance by @​agray in
thomhurst/TUnit#5110
* perf: remove unnecessary lock in ObjectTracker.TrackObjects by
@​thomhurst in thomhurst/TUnit#5217
* perf: eliminate async state machine in
TestCoordinator.ExecuteTestAsync by @​thomhurst in
thomhurst/TUnit#5216
* perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync
by @​thomhurst in thomhurst/TUnit#5215
* perf: consolidate module initializers into single .cctor via partial
class by @​thomhurst in thomhurst/TUnit#5218
### Dependencies
* chore(deps): update tunit to 1.20.0 by @​thomhurst in
thomhurst/TUnit#5205
* chore(deps): update dependency nunit3testadapter to 6.2.0 by
@​thomhurst in thomhurst/TUnit#5206
* chore(deps): update dependency cliwrap to 3.10.1 by @​thomhurst in
thomhurst/TUnit#5207


**Full Changelog**:
thomhurst/TUnit@v1.20.0...v1.21.0

## 1.20.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.20.0 -->

## What's Changed
### Other Changes
* Fix inverted colors in HTML report ring chart due to locale-dependent
decimal formatting by @​Copilot in
thomhurst/TUnit#5185
* Fix nullable warnings when using Member() on nullable properties by
@​Copilot in thomhurst/TUnit#5191
* Add CS8629 suppression and member access expression matching to
IsNotNullAssertionSuppressor by @​Copilot in
thomhurst/TUnit#5201
* feat: add ConfigureAppHost hook to AspireFixture by @​thomhurst in
thomhurst/TUnit#5202
* Fix ConfigureTestConfiguration being invoked twice by @​thomhurst in
thomhurst/TUnit#5203
* Add IsEquivalentTo assertion for Memory<T> and ReadOnlyMemory<T> by
@​thomhurst in thomhurst/TUnit#5204
### Dependencies
* chore(deps): update dependency gitversion.tool to v6.6.2 by
@​thomhurst in thomhurst/TUnit#5181
* chore(deps): update dependency gitversion.msbuild to 6.6.2 by
@​thomhurst in thomhurst/TUnit#5180
* chore(deps): update tunit to 1.19.74 by @​thomhurst in
thomhurst/TUnit#5179
* chore(deps): update verify to 31.13.3 by @​thomhurst in
thomhurst/TUnit#5182
* chore(deps): update verify to 31.13.5 by @​thomhurst in
thomhurst/TUnit#5183
* chore(deps): update aspire to 13.1.3 by @​thomhurst in
thomhurst/TUnit#5189
* chore(deps): update dependency stackexchange.redis to 2.12.4 by
@​thomhurst in thomhurst/TUnit#5193
* chore(deps): update microsoft/setup-msbuild action to v3 by
@​thomhurst in thomhurst/TUnit#5197


**Full Changelog**:
thomhurst/TUnit@v1.19.74...v1.20.0

## 1.19.74

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.74 -->

## What's Changed
### Other Changes
* feat: per-hook activity spans with method names by @​thomhurst in
thomhurst/TUnit#5159
* fix: add tooltip to truncated span names in HTML report by @​thomhurst
in thomhurst/TUnit#5164
* Use enum names instead of numeric values in test display names by
@​Copilot in thomhurst/TUnit#5178
* fix: resolve CS8920 when mocking interfaces whose members return
static-abstract interfaces by @​lucaxchaves in
thomhurst/TUnit#5154
### Dependencies
* chore(deps): update tunit to 1.19.57 by @​thomhurst in
thomhurst/TUnit#5157
* chore(deps): update dependency gitversion.msbuild to 6.6.1 by
@​thomhurst in thomhurst/TUnit#5160
* chore(deps): update dependency gitversion.tool to v6.6.1 by
@​thomhurst in thomhurst/TUnit#5161
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5163
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5162
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5166
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5167
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5168
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5169
* chore(deps): update dependency coverlet.collector to 8.0.1 by
@​thomhurst in thomhurst/TUnit#5177

## New Contributors
* @​lucaxchaves made their first contribution in
thomhurst/TUnit#5154

**Full Changelog**:
thomhurst/TUnit@v1.19.57...v1.19.74

Commits viewable in [compare
view](thomhurst/TUnit@v1.19.57...v1.21.6).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.19.57&new-version=1.21.6)](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>
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.

[Feature]: Suppress nullable warning

2 participants