From 74dda5dc37189c2445187a2300530fc1cc9e473f Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Sat, 12 Sep 2026 00:21:34 -0700 Subject: [PATCH] Coverage: a tail-called body is still a body that ran 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) Claude-Session: https://claude.ai/code/session_01JFTVMTH6yEktHDPF5csksb --- pyproject.toml | 2 +- src/user_calls.cpp | 8 ++++++++ tests/test_coverage.cpp | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6e4dedd..16a0fae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "1.20.1" +version = "1.20.2" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/src/user_calls.cpp b/src/user_calls.cpp index c7b13cf..919503f 100644 --- a/src/user_calls.cpp +++ b/src/user_calls.cpp @@ -691,6 +691,14 @@ void Evaluator::recordTailCallHop(const std::string& calleeName, const oscad::AS // and never see the NEW one at all. noteActiveDeclExit(frame.declNode); noteActiveDeclEnter(&calleeDecl); + // Body coverage: this declaration was entered, exactly as in + // enterUserCall. A hop is the ONLY way into a tail-called body, so + // without this a function reached only in tail position reports its + // body as never run while its own branch arms report hits -- which + // reads as "the condition was not covered but the branches were". + // BOSL2's _str_split_recurse is the shape that shows it: recursion + // through a ternary arm is a tail call every time. + coverHit(calleeDecl); frame.name = calleeName; frame.declNode = &calleeDecl; frame.declPosition = &calleeDecl.position(); diff --git a/tests/test_coverage.cpp b/tests/test_coverage.cpp index d7863c4..15a0d8e 100644 --- a/tests/test_coverage.cpp +++ b/tests/test_coverage.cpp @@ -168,6 +168,33 @@ TEST(Coverage, VmAndInterpreterAgree) { } } +// A tail call hops the frame instead of pushing one, so it never reaches +// enterUserCall -- where the body hit is recorded. A function reached ONLY +// in tail position therefore reported its body as never run while its own +// ternary arms reported hits, which reads on screen as "the condition is +// uncovered but both branches are covered". BOSL2's _str_split_recurse is +// the shape that shows it: recursion through a ternary arm is a tail call +// every time. +TEST(Coverage, TailCalledBodyIsCounted) { + const std::string code = + "function inner(i) = i == 0 ? \"done\" : inner(i-1);\n" + "function tail_caller(i) = inner(i);\n" + "echo(tail_caller(2));\n"; + for (bool useVm : {true, false}) { + ScopedVm vm(useVm); + const CoverageResult r = cover(code); + std::uint32_t innerBody = 0, arms = 0; + for (const CoverageSpan& s : r.spans) { + if (s.kind == CoverageKind::Body && s.line == 1) innerBody = s.hits; + if (s.kind == CoverageKind::Branch && s.line == 1) arms += s.hits; + } + EXPECT_GT(innerBody, 0u) << (useVm ? "VM" : "interpreter") + << ": a body reached only by tail calls still ran"; + EXPECT_EQ(innerBody, arms) << (useVm ? "VM" : "interpreter") + << ": every entry takes exactly one arm"; + } +} + TEST(Coverage, PerFilePercentages) { const CoverageResult r = cover( "function f(x) = x > 0 ? 1 : 2;\n" // body hit; arms: 1 of 2