Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,22 @@ private static bool IsAlwaysFalse(IInvocationOperation operation)
"IsTrue" => GetConditionArgument(operation) is { ConstantValue: { HasValue: true, Value: false } },
"IsFalse" => GetConditionArgument(operation) is { ConstantValue: { HasValue: true, Value: true } },
"AreEqual" => GetEqualityStatus(operation, ExpectedParameterName) == EqualityStatus.NotEqual,
"AreNotEqual" => GetEqualityStatus(operation, NotExpectedParameterName) == EqualityStatus.Equal,
"AreNotEqual" => GetEqualityStatus(operation, NotExpectedParameterName) == EqualityStatus.Equal
|| HasIdenticalExpectedAndActual(operation, NotExpectedParameterName),
"AreNotSame" => HasIdenticalExpectedAndActual(operation, NotExpectedParameterName),
"IsNotNull" => GetValueArgument(operation) is { ConstantValue: { HasValue: true, Value: null } },
"IsNull" => GetValueArgument(operation) is { } valueArgumentOperation && IsNotNullableType(valueArgumentOperation),
_ => false,
};

private static bool HasIdenticalExpectedAndActual(IInvocationOperation operation, string expectedOrNotExpectedParameterName)
=> GetRawArgumentValueWithName(operation, expectedOrNotExpectedParameterName) is { } expectedArgument
&& GetRawArgumentValueWithName(operation, ActualParameterName) is { } actualArgument
&& expectedArgument.IsEquivalentReferenceTo(actualArgument);

private static IOperation? GetRawArgumentValueWithName(IInvocationOperation operation, string name)
=> operation.Arguments.FirstOrDefault(arg => arg.Parameter?.Name == name)?.Value;

private static bool IsNotNullableType(IOperation valueArgumentOperation)
{
ITypeSymbol? valueArgType = valueArgumentOperation.GetReferencedMemberOrLocalOrParameter().GetReferencedMemberOrLocalOrParameter();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,23 @@ private static bool IsAlwaysTrue(IInvocationOperation operation)
{
"IsTrue" => GetConditionArgument(operation) is { ConstantValue: { HasValue: true, Value: true } },
"IsFalse" => GetConditionArgument(operation) is { ConstantValue: { HasValue: true, Value: false } },
"AreEqual" => GetEqualityStatus(operation, ExpectedParameterName) == EqualityStatus.Equal,
"AreEqual" => GetEqualityStatus(operation, ExpectedParameterName) == EqualityStatus.Equal
|| HasIdenticalExpectedAndActual(operation, ExpectedParameterName),
"AreNotEqual" => GetEqualityStatus(operation, NotExpectedParameterName) == EqualityStatus.NotEqual,
"AreSame" => HasIdenticalExpectedAndActual(operation, ExpectedParameterName),
"IsNull" => GetValueArgument(operation) is { ConstantValue: { HasValue: true, Value: null } },
"IsNotNull" => GetValueArgument(operation) is { } valueArgumentOperation && IsNotNullableType(valueArgumentOperation),
_ => false,
};

private static bool HasIdenticalExpectedAndActual(IInvocationOperation operation, string expectedOrNotExpectedParameterName)
=> GetRawArgumentValueWithName(operation, expectedOrNotExpectedParameterName) is { } expectedArgument
&& GetRawArgumentValueWithName(operation, ActualParameterName) is { } actualArgument
&& expectedArgument.IsEquivalentReferenceTo(actualArgument);

private static IOperation? GetRawArgumentValueWithName(IInvocationOperation operation, string name)
=> operation.Arguments.FirstOrDefault(arg => arg.Parameter?.Name == name)?.Value;

private static bool IsNotNullableType(IOperation valueArgumentOperation)
{
ITypeSymbol? valueArgType = valueArgumentOperation.GetReferencedMemberOrLocalOrParameter().GetReferencedMemberOrLocalOrParameter();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,69 @@ public static IOperation WalkDownBuiltInConversion(this IOperation operation)

return operation;
}

/// <summary>
/// Returns <see langword="true"/> when the two operations are structurally equivalent
/// side-effect-free references to the same local, parameter, field, property, or <c>this</c>.
/// </summary>
/// <remarks>
/// This intentionally excludes method invocations and indexer accesses, since those may
/// have side effects or return different values on repeated evaluation. Parenthesized
/// expressions and <em>built-in</em> conversions are skipped transparently, but
/// user-defined conversions are treated as opaque because their operators may execute
/// arbitrary code with side effects.
/// </remarks>
public static bool IsEquivalentReferenceTo(this IOperation? left, IOperation? right)
{
if (left is null || right is null)
{
return false;
}

left = Unwrap(left);
right = Unwrap(right);

return (left, right) switch
{
(ILocalReferenceOperation la, ILocalReferenceOperation lb) =>
SymbolEqualityComparer.Default.Equals(la.Local, lb.Local),

(IParameterReferenceOperation pa, IParameterReferenceOperation pb) =>
SymbolEqualityComparer.Default.Equals(pa.Parameter, pb.Parameter),

(IFieldReferenceOperation fa, IFieldReferenceOperation fb) =>
SymbolEqualityComparer.Default.Equals(fa.Field, fb.Field) &&
AreInstancesEquivalent(fa.Instance, fb.Instance),

(IPropertyReferenceOperation pra, IPropertyReferenceOperation prb) =>
!pra.Property.IsIndexer &&
SymbolEqualityComparer.Default.Equals(pra.Property, prb.Property) &&
AreInstancesEquivalent(pra.Instance, prb.Instance),

Comment on lines +95 to +97

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[MAJOR] Algorithmic Correctness / Analyzer Quality — false positive on custom indexers

The IPropertyReferenceOperation arm does not exclude custom indexers (Property.IsIndexer == true). In Roslyn, list[0] on any type that has a C# indexer (List<T>, Dictionary<K,V>, ImmutableArray<T>, any user-defined this[...] accessor) produces an IPropertyReferenceOperation whose Property.IsIndexer == true. The current code checks only that the property symbol and the receiver instance are equivalent — it does not compare the index arguments.

Concrete false-positive scenario:

var list = new List<string> { "a", "b" };
// list[0] == "a", list[1] == "b" — clearly not always equal, but
Assert.AreEqual(list[0], list[1]);   // ← MSTEST0032 fires incorrectly
Assert.AreSame(list[0], list[1]);    // ← same false-positive path

Both operands produce IPropertyReferenceOperation with the same Item property symbol and the same list local as instance. The switch arm returns true, causing a spurious diagnostic.

Note: plain array access (arr[i]) produces IArrayElementReferenceOperation, not IPropertyReferenceOperation, so that case is already excluded by the _ => false default. Only C#-defined indexers hit this arm.

The <remarks> doc comment already states the correct intent"This intentionally excludes method invocations and indexer accesses" — but the code doesn't enforce it for IPropertyReferenceOperation.

Recommended fix — add !pra.Property.IsIndexer as the first guard:

(IPropertyReferenceOperation pra, IPropertyReferenceOperation prb) =>
    !pra.Property.IsIndexer &&
    SymbolEqualityComparer.Default.Equals(pra.Property, prb.Property) &&
    AreInstancesEquivalent(pra.Instance, prb.Instance),

A matching no-diagnostic test is also needed, e.g.:

var list = new List<object>();
Assert.AreEqual(list[0], list[1]);  // different indices → no diagnostic
Assert.AreEqual(list[i], list[i]);  // same-variable index, still no diagnostic (side-effect risk)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch -- fixed in b220434. Added !pra.Property.IsIndexer as the first guard on the IPropertyReferenceOperation arm so list[0] / dict[k] / any user-defined indexer access falls through to _ => false. The xmldoc remark already documented this intent (This intentionally excludes method invocations and indexer accesses); the code now enforces it. Added WhenAssertAreEqualIsPassedIndexerAccess_NoDiagnostic exercising list[0] vs list[1], list[0] vs list[0], and the AreSame variant. Review reply handled.

(IInstanceReferenceOperation, IInstanceReferenceOperation) => true,

_ => false,
};

static IOperation Unwrap(IOperation operation)
{
while (true)
{
switch (operation)
{
case IParenthesizedOperation parenthesized:
operation = parenthesized.Operand;
break;
case IConversionOperation conversion when !conversion.Conversion.IsUserDefined:
operation = conversion.Operand;
break;
default:
return operation;
}
Comment thread
Copilot marked this conversation as resolved.
}
}

static bool AreInstancesEquivalent(IOperation? a, IOperation? b)
=> (a is null && b is null) || IsEquivalentReferenceTo(a, b);
}
}
Loading
Loading