Skip to content

fix(coverage): track nested ternary branches - #16828

Open
gomesalexandre wants to merge 2 commits into
foundry-rs:masterfrom
gomesalexandre:fix_coverage_ternary_branch
Open

gomesalexandre wants to merge 2 commits into
foundry-rs:masterfrom
gomesalexandre:fix_coverage_ternary_branch

Conversation

@gomesalexandre

Copy link
Copy Markdown
Contributor

closes #16826

forge coverage never gave ternary expressions (cond ? a : b) their own branch coverage item, in
any of the shapes they show up in (bare statement, assignment RHS, return, declaration initializer,
call argument) - only a Statement/line hit that fires whenever either arm runs. A test suite that
only ever exercised one arm was reported as 100% branch coverage, no signal the other arm exists.

What changed

  • A dedicated TernaryVisitor walks every function body once, discovering ExprKind::Ternary
    nodes wherever they're nested (assign RHS/LHS, return, decl init, call args, another ternary's own
    condition/arms) and pushing two Branch coverage items per node. It only pushes Branch items -
    statement coverage stays owned by the pre-existing statement-level visitor, unchanged, so
    % Statements behavior for ternaries is byte-identical to main.
  • crates/evm/coverage/src/anchors.rs's anchor resolution gets an exact mode used only for
    ternary branches: instead of "does this JUMPI's source-mapped range overlap/fall inside a
    containing span" (the existing heuristic used for if/require, which resolves to the last
    matching JUMPI in the span), a ternary branch requires the JUMPI's own source-map element to match
    the ternary's full expression span by exact (source_id, offset, length) equality.

Why the naive fix (tried first, not part of this diff) was rejected

The obvious approach - reuse require()'s pattern verbatim, anchoring two Branch items on the
ternary's span via the existing (non-exact) find_anchor_branch - works for a simple, non-nested
ternary, but breaks on nesting:

function run(bool outer, bool inner) external pure returns (uint) {
    return outer ? (inner ? 1 : 2) : 3;
}

Because the inner ternary's own JUMPI is textually contained inside the outer ternary's span, the
"last matching JUMPI in a containing range" heuristic resolves BOTH the outer and inner branch items
to the same (inner) jump. Running only run(true,true)/run(true,false) (outer-false path never
exercised) printed a false 100% (4/4) branch coverage - worse than the original gap, since it's
confidently wrong rather than merely absent. That failure mode does not occur with this diff (see
receipts below) because exact span equality naturally disambiguates nested decisions: solc emits a
condition-JUMPI mapped to the ternary's own full expression span, and a nested ternary's span is by
construction a strict sub-range, never identical to its parent's.

One real edge case this DOES hit: Solidity inlines modifier bodies into every function using them,
so a ternary inside a shared modifier produces multiple bytecode copies of the same source node,
each with an identical exact span (not ambiguous nesting, just literal duplication of the same
decision). The fix takes the last such match, matching require()'s existing behavior for the same
scenario - covered by a new ternary_modifier regression test.

One semantics note for anyone comparing against if's branch convention: solc's ternary JUMPI jumps
to the true arm, so path 0 = the JUMPI's fallthrough (false arm) and path 1 = the jump target
(true arm) - inverted from how if/require lay out path 0/1. Noted in a code comment at the
TernaryVisitor loop.

Receipts

Real forge coverage output against real fixture contracts (solc 0.8.10, 0.8.30, 0.8.35 all
checked), covering every shape from the issue plus nesting in both arms, nesting in the condition
position, 3-level-deep nesting, and modifier inlining:

simple ternary (all 4 shapes: bare/assign/return/decl), true-only or false-only: 50.00% (1/2)
simple ternary, both arms exercised:                                            100.00% (2/2)

outer ? (inner ? 1 : 2) : 3   -- run(true,true)+run(true,false) only (outer-false never hit):
  75.00% (3/4)   <- correctly PARTIAL, not the false 100% the naive fix produced
outer ? (inner ? 1 : 2) : 3   -- run(false,false) only (inner never reached):
  25.00% (1/4)   <- correctly nonzero, not a false 0%
outer ? (inner ? 1 : 2) : 3   -- all three calls: 100.00% (4/4)

outer ? 1 : (inner ? 2 : 3)  -- mirror ordering, same partial/full pattern (75%/25%/100%)

identity(cond ? 1 : 2) call-argument ternary: 50%/50%/100%, no double-counted call/statement items

ternary in a shared modifier (2 call sites): 100.00% (4/4) statements, 50.00% (1/2) branches,
  does not crash, does not permanently stick at 0%

LCOV (BRDA) confirms the exact original failure case: outer-false shows 0 hits while every
genuinely-exercised branch shows real hit counts.

Full existing coverage regression suite (cargo test -p forge --test cli -- coverage --test-threads 4): test result: ok. 59 passed; 0 failed - every pre-existing coverage test
(branch, branch_with_code_free_else, branch_with_storage_bytes_reads, identical_bytecodes,
require, try_catch, etc.) passes unchanged, plus the 6 new tests this PR adds
(ternary_return, ternary_nested_partial, ternary_nested_outer_only,
ternary_nested_false_partial, ternary_expression_contexts, ternary_modifier). New anchor unit
test ternary_anchor_rejects_missing_node_mapping passes.

Review

This went through a second independent model pass before opening (cross-vendor review gate): it
flagged the modifier-inlining ambiguity issue above (an earlier draft errored instead of taking the
last match, which would have made a modifier's ternary permanently stuck at 0/2 coverage - fixed)
and an unrelated statement-count behavior change (an earlier draft double-removed vs. reverted the
existing enclosing-statement + ternary-expression double statement-item count - reverted to keep
% Statements behavior identical to main, out of scope for this fix).

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Changelog found

The deterministic check will validate the changed entry.

Discover ternary decisions with a branch-only AST traversal and match their jumps by exact source-map spans. This prevents nested decisions from sharing anchors and falsely reporting full branch coverage, while avoiding duplicate statement items.
Allow duplicate exact source spans from modifier inlining to use the last matching jump. Restore existing ternary statement counting and cover shared modifiers with a CLI regression test.
@gomesalexandre
gomesalexandre force-pushed the fix_coverage_ternary_branch branch from 2aa0ed4 to f56a9b9 Compare September 13, 2026 10:34
@gomesalexandre

Copy link
Copy Markdown
Contributor Author

Rebased onto master - conflicted with #16835 ("track both Yul branch outcomes"), which restructured this exact function/match arm. Reconciled both: kept the exact-match ternary threading (is_ternary_branch/find_anchor_branch_inner) on the non-first-opcode branch arm, kept #16835's select_branch/both-outcome jump tracking everywhere. Verified: crate build+tests green (incl. ternary_anchor_rejects_missing_node_mapping), all 60 coverage CLI tests green (incl. Yul and every nested-ternary case), and a revert-then-restore check confirms the exact-match logic is still load-bearing (forcing it off regresses all three nested-ternary tests back to wrong percentages).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

fix(coverage): ternary expressions never get branch coverage tracking

1 participant