Conversation
…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
There was a problem hiding this comment.
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
ExpressionsMatchis correct and handles aliasing/renaming properly. Comparing by syntax text alone (e.g., string matching onvalue.Id) would be fragile; usingSymbolEqualityCompareris the right approach. - Recursive member access chain comparison correctly distinguishes
obj1.Idfromobj2.Idby 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 typesThis 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.
- 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
There was a problem hiding this comment.
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.
value?.Id) — Acknowledged as pre-existing; not addressed here, which is acceptable scope-wise.
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.
[//]: # (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> [](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>
The
IsNotNullAssertionSuppressorwas missing CS8629 ("Nullable value type may be null") and couldn't match member access expressions passed toAssert.That().Two root causes:
Assert.That(value.Id)couldn't match againstvalue.Idin subsequent usage becauseGetIdentifierFromNodereduced everything to the rootIdentifierNameSyntaxChanges
IsNullabilityWarning()andSupportedSuppressionsGetIdentifierFromNode→GetTargetExpressionreturningExpressionSyntaxto preserve full member access chainsExpressionsMatchwith recursive semantic symbol comparison for member access chains, direct symbol comparison for identifiersint?, negative case, anonymous type member access, named type member accessOriginal prompt
📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.