Skip to content

Commit a585d61

Browse files
committed
Harden expression evaluation with fallbacks and sentinels
1 parent 3f6546f commit a585d61

File tree

9 files changed

+198
-10
lines changed

9 files changed

+198
-10
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,4 +421,5 @@ async\method: Suffix with `Async` =\> `GetDataAsync()`
421421
- Reflection helpers should tolerate missing overloads (e.g., `MemoryExtensions.ToArray`) to prevent type initializer failures on runtimes where a method shape differs.
422422
- Compilation can fail with `TypeLoadException`/`ArgumentException` in addition to `InvalidProgramException`; catch broadly and fall back to interpreted compilation or a safe null-returning delegate.
423423
- Prefer `Compile(preferInterpretation: true)` for expression value extraction to avoid invalid IL, and only drop to null when both interpreted compilation and span normalization fail.
424+
- If evaluation still fails, return a sentinel (e.g., `EvaluationUnavailable`) and render `<unavailable: reason>` so diagnostics stay honest instead of showing nulls or bogus counts.
424425
- `NUnit.Assert.ThrowsAsync<T>()` returns the exception instance (not a `Task`), so async tests should await a real async assertion (e.g., `action.Should().ThrowAsync<T>()`) to avoid CS1998 warnings.

learnings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ This document is organized by topic to consolidate key learnings about the proje
132132
- **Reflection resilience:** Do not assume specific overloads exist across runtimes; when reflecting for helpers like `MemoryExtensions.ToArray`, select via tolerant predicate and allow null fallback to avoid type initializer failures.
133133
- **Compilation fallback breadth:** Expression compilation can also fail with `TypeLoadException`/`ArgumentException` on byref-like conversions; wrap compilation and fall back to interpreted mode or a null-returning delegate when both paths fail.
134134
- **Interpretation-first evaluation:** Prefer `Lambda.Compile(preferInterpretation: true)` for expression value extraction to avoid invalid IL generation issues; only return null when both interpreted compilation and span-to-array normalization fail.
135+
- **Evaluation sentinels:** When evaluation fails, return a recognizable sentinel (e.g., `EvaluationUnavailable`) and have formatters render `<unavailable: reason>` instead of null/incorrect data to keep diagnostics honest.
135136
- **NUnit Assert.ThrowsAsync return type:** `Assert.ThrowsAsync<T>()` returns the exception instance, not a `Task`, so async test methods need an explicit awaitable (e.g., `action.Should().ThrowAsync<T>()`) to avoid CS1998 warnings.
136137

137138
## String Diffing Implementation (Increment 5)

src/SharpAssert.Runtime/Features/LinqOperations/LinqOperationFormatter.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using SharpAssert.Core;
44
using SharpAssert.Features.Shared;
55
using static SharpAssert.Features.Shared.ExpressionValueEvaluator;
6+
using static SharpAssert.Features.Shared.EvaluationUnavailableHelpers;
67

78
namespace SharpAssert.Features.LinqOperations;
89

@@ -14,6 +15,9 @@ public static FormattedEvaluationResult BuildResult(MethodCallExpression methodC
1415
{
1516
var methodName = methodCall.Method.Name;
1617
var collection = GetValue(methodCall.Object ?? methodCall.Arguments[0]);
18+
19+
if (IsUnavailable(collection))
20+
return new FormattedEvaluationResult(expressionText, value, new[] { DescribeUnavailable(collection) });
1721

1822
var lines = methodName switch
1923
{
@@ -29,6 +33,9 @@ public static FormattedEvaluationResult BuildResult(MethodCallExpression methodC
2933
static IReadOnlyList<string> FormatContainsFailure(MethodCallExpression methodCall, object? collection)
3034
{
3135
var item = GetValue(methodCall.Arguments.Last()); // Contains item
36+
if (IsUnavailable(item))
37+
return new[] { DescribeUnavailable(item) };
38+
3239
var collectionStr = FormatCollection(collection);
3340
var count = GetCount(collection);
3441

@@ -41,6 +48,9 @@ static IReadOnlyList<string> FormatContainsFailure(MethodCallExpression methodCa
4148

4249
static IReadOnlyList<string> FormatAnyFailure(MethodCallExpression methodCall, object? collection)
4350
{
51+
if (IsUnavailable(collection))
52+
return new[] { DescribeUnavailable(collection) };
53+
4454
var count = GetCount(collection);
4555

4656
if (count == 0)
@@ -57,6 +67,9 @@ static IReadOnlyList<string> FormatAnyFailure(MethodCallExpression methodCall, o
5767

5868
static IReadOnlyList<string> FormatAllFailure(MethodCallExpression methodCall, object? collection)
5969
{
70+
if (IsUnavailable(collection))
71+
return new[] { DescribeUnavailable(collection) };
72+
6073
var predicateStr = GetPredicateString(methodCall);
6174
var predicateArg = GetPredicateArgument(methodCall);
6275

@@ -102,6 +115,9 @@ static bool IsMatching(object? item, Delegate predicate)
102115

103116
static string FormatCollection(object? collection)
104117
{
118+
if (IsUnavailable(collection))
119+
return DescribeUnavailable(collection);
120+
105121
if (collection is not IEnumerable enumerable)
106122
return FormatValue(collection);
107123

src/SharpAssert.Runtime/Features/SequenceEqual/SequenceEqualComparer.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using DiffPlex.Model;
55
using SharpAssert.Features.Shared;
66
using static SharpAssert.Features.Shared.ExpressionValueEvaluator;
7+
using static SharpAssert.Features.Shared.EvaluationUnavailableHelpers;
78

89
namespace SharpAssert.Features.SequenceEqual;
910

@@ -17,6 +18,19 @@ public static SequenceEqualComparisonResult BuildResult(MethodCallExpression met
1718
{
1819
var firstSequence = GetValue(methodCall.Object ?? methodCall.Arguments[0]);
1920
var secondSequence = GetValue(methodCall.Arguments.Count > 1 ? methodCall.Arguments[1] : methodCall.Arguments[0]);
21+
22+
if (IsUnavailable(firstSequence) || IsUnavailable(secondSequence))
23+
{
24+
var error = $"{DescribeUnavailable(firstSequence)}/{DescribeUnavailable(secondSequence)}";
25+
return new SequenceEqualComparisonResult(
26+
new AssertionOperand(firstSequence, typeof(object)),
27+
new AssertionOperand(secondSequence, typeof(object)),
28+
methodCall.Arguments.Count > 2,
29+
null,
30+
null,
31+
false,
32+
$"SequenceEqual failed: value unavailable ({error})");
33+
}
2034

2135
var hasComparer = methodCall.Arguments.Count > 2 ||
2236
(methodCall.Object == null && methodCall.Arguments.Count > 2);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace SharpAssert.Features.Shared;
2+
3+
public sealed record EvaluationUnavailable(string Reason)
4+
{
5+
public override string ToString() => $"<unavailable: {Reason}>";
6+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace SharpAssert.Features.Shared;
2+
3+
static class EvaluationUnavailableHelpers
4+
{
5+
public static bool IsUnavailable(object? value) => value is EvaluationUnavailable;
6+
7+
public static string DescribeUnavailable(object? value) => value is EvaluationUnavailable unavailable
8+
? unavailable.ToString()
9+
: "<unavailable>";
10+
}

src/SharpAssert.Runtime/Features/Shared/ExpressionValueEvaluator.cs

Lines changed: 133 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@ static class ExpressionValueEvaluator
1717
public static Func<object> Compile(Expression expression)
1818
{
1919
var normalized = Normalize(expression);
20-
var lambda = Expression.Lambda<Func<object>>(normalized);
2120

22-
try
23-
{
24-
return lambda.Compile(preferInterpretation: true);
25-
}
26-
catch
27-
{
28-
return NullEvaluator;
29-
}
21+
if (TryInterpret(normalized, out var interpreted))
22+
return interpreted;
23+
24+
if (TryJit(normalized, out var jit))
25+
return jit;
26+
27+
if (TryManualEvaluate(normalized, out var manual))
28+
return manual;
29+
30+
return () => new EvaluationUnavailable("Evaluation failed");
3031
}
3132

3233
static MethodInfo? ResolveToArrayMethod()
@@ -76,6 +77,128 @@ static Expression Normalize(Expression expression)
7677
return Expression.Convert(call, typeof(object));
7778
}
7879

79-
return Expression.Constant(null, typeof(object));
80+
return Expression.Constant(new EvaluationUnavailable("Span conversion unavailable"), typeof(object));
81+
}
82+
83+
static bool TryInterpret(Expression expression, out Func<object> result)
84+
{
85+
try
86+
{
87+
result = Expression.Lambda<Func<object>>(expression).Compile(preferInterpretation: true);
88+
return true;
89+
}
90+
catch
91+
{
92+
result = NullEvaluator;
93+
return false;
94+
}
95+
}
96+
97+
static bool TryJit(Expression expression, out Func<object> result)
98+
{
99+
try
100+
{
101+
result = Expression.Lambda<Func<object>>(expression).Compile();
102+
return true;
103+
}
104+
catch
105+
{
106+
result = NullEvaluator;
107+
return false;
108+
}
109+
}
110+
111+
static bool TryManualEvaluate(Expression expression, out Func<object> result)
112+
{
113+
if (TryEvaluateExpression(expression, out var value))
114+
{
115+
result = () => value;
116+
return true;
117+
}
118+
119+
result = NullEvaluator;
120+
return false;
121+
}
122+
123+
static bool TryEvaluateExpression(Expression expression, out object value)
124+
{
125+
switch (expression)
126+
{
127+
case ConstantExpression constant:
128+
value = constant.Value!;
129+
return true;
130+
case UnaryExpression { NodeType: ExpressionType.Convert } unary when TryEvaluateExpression(unary.Operand, out var operand):
131+
value = operand;
132+
return true;
133+
case MemberExpression member when TryEvaluateMember(member, out var memberValue):
134+
value = memberValue;
135+
return true;
136+
case MethodCallExpression call when TryEvaluateMethodCall(call, out var callResult):
137+
value = callResult;
138+
return true;
139+
default:
140+
value = new EvaluationUnavailable($"Unsupported expression: {expression.NodeType}");
141+
return false;
142+
}
143+
}
144+
145+
static bool TryEvaluateMethodCall(MethodCallExpression call, out object result)
146+
{
147+
if (!TryEvaluateArguments(call, out var args))
148+
{
149+
result = new EvaluationUnavailable("Argument evaluation failed");
150+
return false;
151+
}
152+
153+
try
154+
{
155+
object? obj = null;
156+
var target = call.Object != null && TryEvaluateExpression(call.Object, out obj) ? obj : null;
157+
result = call.Method.Invoke(target, args)!;
158+
return true;
159+
}
160+
catch
161+
{
162+
result = new EvaluationUnavailable("Method invocation failed");
163+
return false;
164+
}
165+
}
166+
167+
static bool TryEvaluateArguments(MethodCallExpression call, out object?[] args)
168+
{
169+
args = new object?[call.Arguments.Count];
170+
171+
for (var i = 0; i < call.Arguments.Count; i++)
172+
{
173+
if (!TryEvaluateExpression(call.Arguments[i], out var arg))
174+
return false;
175+
176+
args[i] = arg;
177+
}
178+
179+
return true;
180+
}
181+
182+
static bool TryEvaluateMember(MemberExpression member, out object value)
183+
{
184+
object? target = null;
185+
var hasTarget = member.Expression != null && TryEvaluateExpression(member.Expression, out target);
186+
187+
if (member.Member is FieldInfo field)
188+
{
189+
value = field.GetValue(hasTarget ? TargetOrNull(target) : null)!;
190+
return true;
191+
}
192+
193+
if (member.Member is PropertyInfo prop)
194+
{
195+
value = prop.GetValue(hasTarget ? TargetOrNull(target) : null)!;
196+
return true;
197+
}
198+
199+
value = new EvaluationUnavailable($"Unsupported member: {member.Member.Name}");
200+
return false;
201+
202+
static object? TargetOrNull(object? targetValue) => targetValue is EvaluationUnavailable ? null : targetValue;
80203
}
81204
}

src/SharpAssert.Runtime/Features/Shared/ValueFormatter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ static class ValueFormatter
77
public static string Format(object? value) => value switch
88
{
99
null => "null",
10+
EvaluationUnavailable unavailable => unavailable.ToString(),
1011
string s => $"\"{s}\"",
1112
DateTime dt => dt.ToString("M/d/yyyy", CultureInfo.InvariantCulture),
1213
_ => value.ToString()!
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using FluentAssertions;
2+
using SharpAssert.Features.Shared;
3+
4+
namespace SharpAssert.Features;
5+
6+
[TestFixture]
7+
public class ValueFormatterFixture
8+
{
9+
[Test]
10+
public void Should_render_unavailable_sentinel()
11+
{
12+
var unavailable = new EvaluationUnavailable("reason");
13+
14+
ValueFormatter.Format(unavailable).Should().Be("<unavailable: reason>");
15+
}
16+
}

0 commit comments

Comments
 (0)