Assert.That follow-ups from #8306 review#8359
Conversation
Addresses three open review threads on the merged PR: * #3264506474 / #3264506501 — assignment and unary-update nodes were skipped entirely during analysis to keep the writable LHS / operand out of CaptureRewriter's non-writable Block wrapping. That kept hand- built expression trees compiling but lost diagnostic detail for any reads on the RHS or beneath the writable target. Move the writable- context guard into CaptureRewriter (new VisitBinary / VisitUnary / VisitWritableTarget) and let AnalyzeExpression process both sides normally. Tested with reused-instance LHS/RHS (e.g. \�ox.Value = box.Value * 2\) to lock down the rubber-duck reused-node concern. * #3264506519 — the comment in EvaluateAndCollectDetails described the Func/Action filter as runtime-type-based but AnalyzeMemberExpression also filtered statically, silently dropping members typed as Func/Action that held a null value. Remove the analysis-time static-type filter, keep the runtime-type filter, and clarify the comment so null Func members render as \ ull\ again (matching the original behavior). Adds three regression tests: * That_ManuallyConstructedAssignExpression_CapturesValuesFromRhsAndLhs * That_ManuallyConstructedAssignExpression_WithReusedLhsInstance_DoesNotThrow * That_WithNullFuncDelegate_RendersAsNullInDetails All 884 AssertTests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Follow-up to #8306 to restore assertion “details” analysis for assignment/unary-update expression trees while keeping rewritten lambdas compilable, and to preserve historical rendering of null Func/Action members.
Changes:
- Move “writable target” handling into
CaptureRewriterso assignment/update nodes can be analyzed without breaking compilation. - Remove analysis-time static Func/Action filtering; keep runtime filtering during detail construction to allow
nullFunc/Action members to render asnull. - Add regression tests covering assignment RHS/LHS capture behavior, reused LHS instance handling, and null Func rendering.
Show a summary per file
| File | Description |
|---|---|
| test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs | Adds regression tests for assignment/unary-update capture behavior and null Func/Action detail rendering. |
| src/TestFramework/TestFramework/Assertions/Assert.That.cs | Reworks capture rewriting to preserve writability for assignment/update targets and adjusts Func/Action filtering semantics/comments. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 5
| // For `obj.Field`/`obj.Property`, visit the receiver normally (a read) but | ||
| // leave the MemberExpression itself writable. | ||
| MemberExpression me when me.Expression is not null | ||
| => me.Update(Visit(me.Expression)), | ||
| BinaryExpression { NodeType: ExpressionType.ArrayIndex } ai | ||
| => ai.Update(Visit(ai.Left)!, ai.Conversion, Visit(ai.Right)!), | ||
| IndexExpression ix when ix.Object is not null | ||
| => ix.Update(Visit(ix.Object), Visit(ix.Arguments)), |
| protected override Expression VisitBinary(BinaryExpression node) | ||
| { | ||
| if (IsAssignmentNodeType(node.NodeType)) | ||
| { | ||
| // Keep the LHS in a writable form (don't wrap it in a Block) so the rewritten | ||
| // lambda still compiles, but recurse into its children so reads beneath the | ||
| // writable target are still captured. The RHS is processed normally. | ||
| Expression left = VisitWritableTarget(node.Left); | ||
| Expression right = Visit(node.Right)!; | ||
| return node.Update(left, node.Conversion, right); | ||
| } |
| public void That_ManuallyConstructedAssignExpression_CapturesValuesFromRhsAndLhs() | ||
| { |
| public void That_ManuallyConstructedAssignExpression_WithReusedLhsInstance_DoesNotThrow() | ||
| { |
| public void That_WithNullFuncDelegate_RendersAsNullInDetails() | ||
| { |
|
Closing this PR — it has been superseded by #8307, which landed on
Re: the three new review comments from Copilot:
Rebasing this PR would mean re-writing it from scratch against a fundamentally different design that already addresses the same concerns differently, so the cleaner action is to close. Heads-up unrelated to this PR: while comparing the two architectures I noticed #8307 regressed two display fixes from #8306 (issue #6691):
Happy to open a separate PR re-porting those if you'd like — let me know. |
Follow-up to #8306 addressing three open review threads.
Review threads addressed
#3264506474 / #3264506501 — assignment / unary-update analysis lost RHS detail
The merged PR skipped assignment-style
BinaryExpressionnodes (=,+=, …) and unary-updateUnaryExpressionnodes (++x,x--, …) entirely during analysis to keep the writable LHS / operand out ofCaptureRewriter's non-writableBlockwrapping. That kept hand-built expression trees compiling but lost diagnostic detail for any reads on the RHS (assignment) or beneath the writable target.Moved the writable-context guard into
CaptureRewriter(newVisitBinary/VisitUnary/VisitWritableTarget) and letAnalyzeExpressionprocess both sides normally. The newVisitWritableTargetleaves the top-level writable node (e.g.obj.FieldLHS) unwrapped while still recursing into its children so reads beneath the writable target are still captured.This also robustly handles the rubber-duck-identified reused-node scenario: in a hand-built tree like
Expression.Assign(field, Expression.Multiply(field, c))where the sameMemberExpressioninstance appears in both LHS and RHS, the rewriter discriminates per syntactic position (LHS stays writable, RHS gets wrapped) rather than per instance.#3264506519 — Func/Action filter comment didn't match the code
The comment in
EvaluateAndCollectDetailsclaimed the Func/Action filter used "runtime type as the existing code did", butAnalyzeMemberExpressionadded a static-type filter that silently dropped members typed as Func/Action holding anullvalue — a behavior regression vs. the original implementation.Removed the analysis-time static-type filter, kept the runtime-type filter at detail-build time, and clarified the comments. Null Func/Action members now render as
nullagain, matching the historical behavior.Other threads on #8306 reviewed but not changed
IsSafeToReevaluaterecurses into children. TestThat_ShortCircuitedExpression_DoesNotReevaluateMemberExpressionWithSideEffectingReceivercovers it.IsCapturedThisusesIsAssignableFromso inherited methods onthisrender correctly. TestThat_InheritedInstanceMethodOnThis_RendersAsThislocks it down.IsArrayGetMethodrequiresObject.Type.IsArray, so arbitraryGet(...)methods don't render as indexers. TestThat_ArbitraryGetMethod_RendersAsMethodCall_NotIndexercovers it.IsCapturedThis. TestThat_ExtensionMethodOnThis_RendersAsThiscovers it.ConditionalWeakTable<Expression, …>is interesting butExpression<Func<bool>>lambdas are typically rebuilt perAssert.Thatcall, so the benefit is speculative without a benchmark. The existing inline comment already documents it as a future mitigation.Tests
Adds three regression tests in
test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs:That_ManuallyConstructedAssignExpression_CapturesValuesFromRhsAndLhs— proves the new behavior: a hand-builtbox.Value = rhsValue + 1tree surfaces bothrhsValueand the post-assignmentbox.Valuein the failure details.That_ManuallyConstructedAssignExpression_WithReusedLhsInstance_DoesNotThrow— locks down the reused-node case where the sameMemberExpressioninstance is both LHS and RHS sub-expression.That_WithNullFuncDelegate_RendersAsNullInDetails— locks down thatFunc<…>-typed members holdingnullrender asnull(regression guard for the comment/code inconsistency).All 884
AssertTestspass locally.