Branch semantics
A branch is one outcome of a decision: the entry of a function, the body of if, elseif,
else, a loop, case, catch and finally, the right operand of &&, ||, and, or and
??, the arms of ?: and match, and the non-null value of ?->.
Strict mode
Strict mode is the default; --no-strict turns off the first and the fourth rules below (the
synthetic else and the null side of ?->).
-
An empty body is an outcome. An empty body of
if,elseif,elseand loops is an outcome like any other: it gets a probe without a line, and counts as a branch.if ($order->isPaid()) {// nothing to do: this outcome still has to be reached by a test} -
Arrow functions become closures.
fn&(...) => exprbecomes the closure it abbreviates,function &(...) use (...) { probe; return expr; }, with the variables of the expression captured by value as the arrow function does;&get => exprbecomes&get { probe; return expr; }. The returned reference stays intact. -
Every
ifneeds itselse. Everyifwithoutelsegets a syntheticelse: both outcomes need to be tested. Xdebug counts basic blocks, so anifwithoutelsewhose condition is always true gives 100% there and not in Sonda.public function total(): int{if ($this->discount !== null) { // a test with a discount, and one withoutreturn $this->subtotal - $this->discount;}return $this->subtotal;} -
?->counts both sides. The null and the non-null outcome of every nullsafe call.$name = $order->customer?->name(); // a test with a customer, and one with null -
The short circuit of
&&and||. The left operand of&&,||,andandorhas to decide alone at least once (the short circuit, when the right one does not even run), as Xdebug counts.if ($user->isAdmin() || $user->owns($order)) { // one test where isAdmin() is true, so owns() never runsNot in
??: its left side may be an undefined key, which only that operator reads without a warning, so only the right operand of??is a branch.$limit = $options['limit'] ?? 10; // only the fallback (right side) needs a test
What Sonda leaves out on purpose
-
The implicit arm of
match. Sonda does not count the implicit "no arm matched" branch ofmatch, which Xdebug counts and no test reaches when the arms cover every case of an enum.return match ($status) {Status::Open => 'open',Status::Closed => 'closed',}; // the arms are the branches; the UnhandledMatchError path is not one -
The fallback jump of a frameless function call. Sonda does not count the fallback jump Xdebug sees in a call to a native frameless function (
in_array,max,preg_replace,str_contains...) made inside a namespace withoutuse function: it is a detail of the opcode, not a decision of the code, and ause functiononly to silence the coverage stops being needed.namespace App;$found = in_array($id, $ids, true); // no "branch" here for Sonda; Xdebug sees the fallback to the global function -
First-class callables credit the method. A method called as a first-class callable (
$this->method(...)) shows in Xdebug as a loose function named after the method, with the hits, while the class method stays at zero; Sonda credits the method.tools/compare.phpmerges the two entries before comparing.
The fidelity of these rules against Xdebug, measured on a whole suite, is in Comparison with Xdebug.