Skip to content

feat(analyzers): detect collection IsEqualTo reference equality (TUnitAssertions0016)#5615

Merged
thomhurst merged 9 commits intomainfrom
feat/collection-isequalto-analyzer
Apr 18, 2026
Merged

feat(analyzers): detect collection IsEqualTo reference equality (TUnitAssertions0016)#5615
thomhurst merged 9 commits intomainfrom
feat/collection-isequalto-analyzer

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Add TUnitAssertions0016 (Info): flags .IsEqualTo(...) on collection assertion sources where reference equality is used instead of content equivalence.
  • Add code fix to rewrite .IsEqualTo(...) to .IsEquivalentTo(...).
  • Filters to the generic EqualsAssertion-returning overload so specialized overloads (Count().IsEqualTo(...), DateTimeEqualsAssertion, ...) are not flagged.
  • Excludes string, Memory<T>, ReadOnlyMemory<T>, Span<T>, ReadOnlySpan<T> from collection detection.

Test plan

  • Analyzer test: List<int>.IsEqualTo raises info
  • Analyzer test: string.IsEqualTo not flagged
  • Analyzer test: int.IsEqualTo not flagged
  • Analyzer test: int[].IsEqualTo raises info
  • Analyzer test: Count().IsEqualTo (specialized overload) not flagged
  • Code fix test: rewrites IsEqualTo to IsEquivalentTo
  • Code fix test: preserves chained .And.IsNotNull() calls
  • Code fix test: works on arrays
  • 85 total tests pass (67 analyzer + 18 codefixer on net9.0)

- Cache IEnumerable symbol via RegisterCompilationStartAction (drop per-call GetTypeByMetadataName)
- Eliminate .Concat(new[] { sourceType }) allocation in interface walk
- Reorder filters: cheap string checks before ToDisplayString()
- Replace dead `node as IdentifierNameSyntax` fallback in code fix with direct pattern match on enclosing invocation
- Import Microsoft.CodeAnalysis.CSharp.Syntax to drop inline fully-qualified names
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: TUnitAssertions0016 — Collection IsEqualTo Reference Equality

Good addition overall. The structure follows existing analyzer patterns well, the code fix is correct and trivia-preserving, and the test matrix is solid. A few things worth discussing:


Design Concern: False Positives for Custom Collections with Value Equality

The core issue: IsCollectionWithoutStructuralEquality checks whether a type implements IEnumerable, but it does not check whether the type overrides Equals. This means the analyzer will fire on any custom collection that does implement structural equality via an Equals override:

public class MyOrderedSet : IEnumerable<int>
{
    public override bool Equals(object? obj) => /* structural comparison */;
    // ...
}

await Assert.That(new MyOrderedSet()).IsEqualTo(new MyOrderedSet()); // ← flagged as Info, but wrong!

The method name IsCollectionWithoutStructuralEquality implies it checks for absence of structural equality, but it actually only checks for presence of IEnumerable. The fix is to also verify that Equals is not overridden beyond object:

private static bool IsCollectionWithoutStructuralEquality(ITypeSymbol type, INamedTypeSymbol ienumerable)
{
    // ... existing exclusions ...

    if (!ImplementsIEnumerable(type, ienumerable)) return false;

    // Only flag if Equals is not overridden (i.e., reference equality is used)
    return !HasEqualsOverride(type);
}

private static bool HasEqualsOverride(ITypeSymbol type)
{
    var current = type;
    while (current is not null && current.SpecialType != SpecialType.System_Object)
    {
        if (current.GetMembers("Equals").OfType<IMethodSymbol>()
            .Any(m => m.IsOverride && m.Parameters.Length == 1 &&
                      m.Parameters[0].Type.SpecialType == SpecialType.System_Object))
            return true;
        current = current.BaseType;
    }
    return false;
}

This would make the diagnostic semantically accurate and eliminate false positives.


Missing Test: Custom IEnumerable with Equals Override

There is no test covering the false-positive case described above — a custom IEnumerable-implementing type that overrides Equals. Adding that test would both document the intended behavior and guard against regressions.


Minor: Resources.resx Missing Trailing Newline

The diff shows \ No newline at end of file for Resources.resx. Other entries in that file likely have a trailing newline; this should be consistent.


Observation: CompilationStart for IEnumerable Symbol Caching

Wrapping the operation action in RegisterCompilationStartAction to cache the IEnumerable symbol is a good pattern — it avoids re-resolving the symbol on every invocation. This is better than the approach in some other analyzers (e.g., PreferIsNullAnalyzer) that don't cache. Worth keeping, and potentially worth applying to other analyzers in a follow-up.


Summary

Overall Good implementation with one real design gap
Blocking False-positive for custom IEnumerable types that override Equals — the diagnostic message would be incorrect in that case
Non-blocking Missing negative test for that case; Resources.resx trailing newline

The fix for the blocking issue is straightforward: add an Equals-override check inside IsCollectionWithoutStructuralEquality. Everything else — the code fix provider, the span reporting, the chained-call preservation, the exclusions for string/Memory/Span — looks correct.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 17, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 59 complexity

Metric Results
Complexity 59

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

…uals

Custom IEnumerable types (including records) that override Equals(object)
implement structural equality, so .IsEqualTo is semantically correct and
must not be flagged. Add OverridesObjectEquals walk up the base chain and
gate collection detection on it. Covers the previously false-positive case.

Also fixes missing trailing newline in Resources.resx.
@thomhurst
Copy link
Copy Markdown
Owner Author

Thanks for the review. Addressed in 0d4f6ce51:

  • Blocking — false positive on custom collections with Equals override: added OverridesObjectEquals walk up the base chain; IsCollectionWithoutStructuralEquality now returns false when an override is present. Covers records (synthesized Equals(object) override) and hand-rolled overrides.
  • Missing test: added CustomEnumerable_With_EqualsOverride_Not_Flagged and Record_Collection_Not_Flagged.
  • Resources.resx trailing newline: fixed.

All 69 analyzer tests + 18 code fixer tests pass (net9.0).

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: TUnitAssertions0016 — Follow-up (v2)

Previous Review vs. Current Code

My prior review flagged three issues. Looking at the current diff carefully, all three were already present and handled in the original submission — the review was inaccurate. To set the record straight:

Previous concern Status
False positives for custom IEnumerable with Equals override Already handled — OverridesObjectEquals was already in the code
Missing test for custom IEnumerable + Equals override Already present — CustomEnumerable_With_EqualsOverride_Not_Flagged test
Resources.resx trailing newline Fixed — the diff no longer shows \ No newline at end of file

Apologies for the noise in the prior review.


New Concern: IEquatable<T> Gap

OverridesObjectEquals checks for Equals(object) overrides but not for IEquatable\<T\> implementation. This matters because EqualityComparer\<T\>.Default (which TUnit's IsEqualTo likely uses) prefers IEquatable\<T\>.Equals when implemented, making the comparison structural — not reference-based.

A type like this would be falsely flagged:

public class MyCollection : IEnumerable<int>, IEquatable<MyCollection>
{
    public bool Equals(MyCollection? other) => /* structural */;
    // Does NOT override Equals(object) separately
}

await Assert.That(new MyCollection()).IsEqualTo(new MyCollection()); // ← falsely flagged

Fix — extend the guard in IsCollectionWithoutStructuralEquality:

private static bool ImplementsIEquatableOfSelf(INamedTypeSymbol type)
{
    return type.AllInterfaces.Any(i =>
        i.Name == "IEquatable" &&
        i.TypeArguments.Length == 1 &&
        SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], type));
}

And add || ImplementsIEquatableOfSelf(...)" to the return condition alongside OverridesObjectEquals`.

This is a low-probability edge case (most types that implement IEquatable\<T\> also override Equals(object)), but worth closing for correctness.


Everything Else Looks Good

  • RegistrationCompilationStartAction + caching the IEnumerable symbol: correct and efficient.
  • Span reporting (TextSpan.FromBounds) is precise — highlights only IsEqualTo(...) rather than the full chain.
  • Code fix correctly replaces the identifier via SyntaxFactory.IdentifierName with trivia preserved, leaving chained calls intact.
  • Exclusions for string, Memory\<T\>, Span\<T\>, ReadOnlySpan\<T\>, ReadOnlyMemory\<T\> are correct and tested.
  • Record exclusion (Record_Collection_Not_Flagged) correctly handles synthesised Equals from record types.
  • WellKnownFixAllProviders.BatchFixer is the right choice for a simple rename fix.

The only actionable gap is the IEquatable\<T\> case. Given that DiagnosticSeverity.Info keeps this non-blocking for users, this is non-blocking for the PR as well — but worth a follow-up or inline fix.

…Equatable<self>

EqualityComparer<T>.Default prefers IEquatable<T>.Equals when implemented,
so a custom IEnumerable that implements IEquatable<self> without overriding
Equals(object) still has structural equality semantics. Gate the flag on
both checks and cover with a regression test.
@thomhurst
Copy link
Copy Markdown
Owner Author

Addressed the IEquatable<T> gap in da8001ee1:

  • Added ImplementsIEquatableOfSelf walk over AllInterfaces, comparing the type argument to the implementing type with SymbolEqualityComparer.Default.
  • IsCollectionWithoutStructuralEquality now gates on both !OverridesObjectEquals && !ImplementsIEquatableOfSelf.
  • New test CustomEnumerable_With_IEquatable_Not_Flagged covers the case where IEquatable<self> is implemented without an Equals(object) override.

8/8 CollectionIsEqualToAnalyzerTests pass.

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: TUnitAssertions0016 — v3 (current state)

Previous Reviews vs. Current State

The PR has evolved through two prior rounds of review feedback:

Concern Addressed by
False positives for custom IEnumerable without Equals override (Review 1) commit 0d4f6ce5 — added OverridesObjectEquals
IEquatable<T> implementations not excluded (Review 2) commit da8001ee — added ImplementsIEquatableOfSelf

Both concerns are now correctly handled and tested. The analyzer properly skips collections that implement structural equality via either mechanism.


Current Code Assessment

IsCollectionWithoutStructuralEquality now correctly returns false when:

  • The type is string, Memory<T>, ReadOnlyMemory<T>, Span<T>, or ReadOnlySpan<T>
  • The type does not implement IEnumerable
  • The type overrides Equals(object)
  • The type implements IEquatable<T> of itself

This is semantically accurate. The return condition !OverridesObjectEquals(type) && !ImplementsIEquatableOfSelf(type) is correct and complete.


Minor Observation: Redundant Span/Memory Exclusions

Span<T> and ReadOnlySpan<T> are ref structs and cannot implement interfaces — so ImplementsIEnumerable would already return false for them. Similarly, Memory<T> and ReadOnlyMemory<T> do not implement IEnumerable. The explicit exclusions are technically redundant, but they serve as clear documentation of intent and add no cost. Not worth changing.


Everything Looks Good

  • Analyzer logic: Sound. The CompilationStartAction pattern for caching the IEnumerable symbol is correct and efficient.
  • Span reporting: TextSpan.FromBounds(memberAccess.Name.SpanStart, invocationSyntax.Span.End) highlights only IsEqualTo(...) rather than the full chain. Precise and correct.
  • Code fix: Simple identifier rename via SyntaxFactory.IdentifierName with WithTriviaFrom is the right approach. Preserves chained calls and does not require any AST restructuring.
  • GetFixAllProvider: WellKnownFixAllProviders.BatchFixer is correct for a pure rename fix.
  • Test coverage: All meaningful cases are covered — List, array, string, int, Count() overload, record, IEquatable, Equals override.

This is ready to merge.

@thomhurst thomhurst enabled auto-merge (squash) April 18, 2026 00:17
@thomhurst thomhurst merged commit 5f2b7c1 into main Apr 18, 2026
15 checks passed
@thomhurst thomhurst deleted the feat/collection-isequalto-analyzer branch April 18, 2026 00:43
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 21, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.35.2 to
1.37.10.

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

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

## 1.37.10

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

## What's Changed
### Other Changes
* docs(test-filters): add migration callout for --filter →
--treenode-filter by @​johnkattenhorn in
thomhurst/TUnit#5628
* fix: re-enable RPC tests and modernize harness (#​5540) by @​thomhurst
in thomhurst/TUnit#5632
* fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch
(#​5626) by @​JohnVerheij in
thomhurst/TUnit#5631
* ci: use setup-dotnet built-in NuGet cache by @​thomhurst in
thomhurst/TUnit#5635
* feat(playwright): propagate W3C trace context into browser contexts by
@​thomhurst in thomhurst/TUnit#5636
### Dependencies
* chore(deps): update tunit to 1.37.0 by @​thomhurst in
thomhurst/TUnit#5625

## New Contributors
* @​johnkattenhorn made their first contribution in
thomhurst/TUnit#5628
* @​JohnVerheij made their first contribution in
thomhurst/TUnit#5631

**Full Changelog**:
thomhurst/TUnit@v1.37.0...v1.37.10

## 1.37.0

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

## What's Changed
### Other Changes
* fix: stabilize flaky tests across analyzer, OTel, and engine suites by
@​thomhurst in thomhurst/TUnit#5609
* perf: engine hot-path allocation wins (#​5528 B) by @​thomhurst in
thomhurst/TUnit#5610
* feat(analyzers): detect collection IsEqualTo reference equality
(TUnitAssertions0016) by @​thomhurst in
thomhurst/TUnit#5615
* perf: consolidate test dedup + hook register guards (#​5528 A) by
@​thomhurst in thomhurst/TUnit#5612
* perf: engine discovery/init path cleanup (#​5528 C) by @​thomhurst in
thomhurst/TUnit#5611
* fix(assertions): render collection contents in IsEqualTo failure
messages (#​5613 B) by @​thomhurst in
thomhurst/TUnit#5619
* feat(analyzers): code-fix for TUnit0015 to insert CancellationToken
(#​5613 D) by @​thomhurst in
thomhurst/TUnit#5621
* fix(assertions): add Task reference forwarders on
AsyncDelegateAssertion by @​thomhurst in
thomhurst/TUnit#5618
* test(asp-net): fix race in FactoryMethodOrderTests by @​thomhurst in
thomhurst/TUnit#5623
* feat(analyzers): code-fix for TUnit0049 to insert [MatrixDataSource]
(#​5613 C) by @​thomhurst in
thomhurst/TUnit#5620
* fix(pipeline): isolate AOT publish outputs to stop clobbering pack
DLLs (#​5622) by @​thomhurst in
thomhurst/TUnit#5624
### Dependencies
* chore(deps): update tunit to 1.36.0 by @​thomhurst in
thomhurst/TUnit#5608
* chore(deps): update modularpipelines to 3.2.8 by @​thomhurst in
thomhurst/TUnit#5614


**Full Changelog**:
thomhurst/TUnit@v1.36.0...v1.37.0

## 1.36.0

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

## What's Changed
### Other Changes
* fix: don't render test's own trace as Linked Trace in HTML report by
@​thomhurst in thomhurst/TUnit#5580
* fix(docs): benchmark index links 404 by @​thomhurst in
thomhurst/TUnit#5587
* docs: replace repeated benchmark link suffix with per-test
descriptions by @​thomhurst in
thomhurst/TUnit#5588
* docs: clearer distributed tracing setup and troubleshooting by
@​thomhurst in thomhurst/TUnit#5597
* fix: auto-suppress ExecutionContext flow for hosted services (#​5589)
by @​thomhurst in thomhurst/TUnit#5598
* feat: auto-align DistributedContextPropagator to W3C by @​thomhurst in
thomhurst/TUnit#5599
* feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory
inheritance by @​thomhurst in
thomhurst/TUnit#5601
* feat: auto-propagate test trace context through IHttpClientFactory by
@​thomhurst in thomhurst/TUnit#5603
* feat: TUnit.OpenTelemetry zero-config tracing package by @​thomhurst
in thomhurst/TUnit#5602
* fix: restore [Obsolete] members removed in v1.27 (#​5539) by
@​thomhurst in thomhurst/TUnit#5605
* feat: generalize OTLP receiver for use outside TUnit.Aspire by
@​thomhurst in thomhurst/TUnit#5606
* feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT by
@​thomhurst in thomhurst/TUnit#5607
### Dependencies
* chore(deps): update tunit to 1.35.2 by @​thomhurst in
thomhurst/TUnit#5581
* chore(deps): update dependency typescript to ~6.0.3 by @​thomhurst in
thomhurst/TUnit#5582
* chore(deps): update dependency coverlet.collector to v10 by
@​thomhurst in thomhurst/TUnit#5600


**Full Changelog**:
thomhurst/TUnit@v1.35.2...v1.36.0

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

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

1 participant