Coverage: a tail-called body is still a body that ran - #175
Merged
Merged
Conversation
recordTailCallHop never recorded the coverage hit that enterUserCall
does. A tail call HOPS the frame instead of pushing one, so it never
reaches enterUserCall -- and a hop is the only way into a tail-called
body. A function reached only in tail position therefore reported its
body as never run while its own ternary arms reported hits.
On screen that reads as something impossible: the condition is uncovered
but both branches are covered. There is no span for a ternary's
condition -- the body span covers it -- so an uncovered body paints the
declaration and the condition red while the covered arms stay green.
BOSL2's _str_split_recurse is the shape that shows it, and recursion
through a ternary arm is a tail call every time:
L239 body hits=0 function _str_split_recurse(...) =
L240 branch hits=1 concat(result,[str])
L241 branch hits=2 let(...)
Now 3, 1 and 2 -- the body's count is the sum of its arms, as it must be.
Minimal repro, same function reached two ways:
function inner(i) = i == 0 ? "done" : inner(i-1);
function tail_caller(i) = inner(i); // body hits 0
function nontail_caller(i) = str("", inner(i)); // body hits 1
The hit goes in recordTailCallHop rather than at the Op::CallFnTail call
site, next to the noteActiveDeclExit/Enter pair that is already there for
exactly the same reason: a hop swaps which declaration occupies the frame
and every piece of per-declaration bookkeeping has to follow it.
Coverage.VmAndInterpreterAgree would have caught this if anything in it
had recursed in tail position; the new test does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTVMTH6yEktHDPF5csksb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported from the GUI overlay as "a ternary whose condition isn't covered, but whose branches are" — which should be impossible.
What was happening
recordTailCallHopnever recorded the coverage hit thatenterUserCalldoes. A tail call hops the frame instead of pushing one, so it never reachesenterUserCall— and a hop is the only way into a tail-called body. A function reached only in tail position reported its body as never run, while its own arms reported hits.There is no span for a ternary's condition — the body span covers it. So an uncovered body paints the declaration line and the condition red while the covered arms stay green, which is exactly what it looked like on screen.
BOSL2's
_str_split_recurseis the shape that shows it, because recursion through a ternary arm is a tail call every time:Now
3,1,2— the body's count is the sum of its arms, as it must be.Minimal repro
Same function, same work, only the call shape differs:
The fix
One
coverHit(calleeDecl)inrecordTailCallHop, beside thenoteActiveDeclExit/noteActiveDeclEnterpair already there for the same reason: a hop swaps which declaration occupies the frame, so every piece of per-declaration bookkeeping has to follow it. Putting it there rather than at theOp::CallFnTailcall site keeps it on the one path every hop takes.Tests
Coverage.VmAndInterpreterAgreewould have caught this years ago if anything in it recursed in tail position — nothing did. The newCoverage.TailCalledBodyIsCountedruns under both the VM and the interpreter and asserts the body count equals the sum of the arms. Verified it fails with the fix reverted.1258 C++ tests and 42 binding tests pass.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JFTVMTH6yEktHDPF5csksb