Skip to content

Scoring rules

Three rules govern every increment: shorthand that doesn’t break reading flow is free; +1 for each break in the linear flow of the code; +nesting for a flow-breaker that sits inside other flow-breakers.

Construct Increment Raises nesting
if, ternary +1 +nesting yes
else if / elseif, else +1 flat yes
switch, match +1 +nesting (not per arm) yes
for, foreach, for…of, for…in, while, do +1 +nesting yes
catch +1 +nesting yes
try, finally none no
Labelled jump (break 2, break outer, goto) +1 no
Sequence of like boolean operators +1 per run no
Direct recursion +1 no
Closure, arrow function, nested function none yes
Class, interface, trait, enum, namespace, object literal none no

else if takes a flat increment deliberately: a long chain reads linearly, so penalising it for depth would misrepresent it.

A loop’s header (the for initialiser and update, the foreach subject, a while condition) is read before the body, so it sits at the loop’s own nesting level rather than one deeper.

Boolean operators cost per run, not per operator. The cost is in the switching:

$a && $b && $c // +1 one run
$a && $b || $c // +2 two runs
$a && $b || $c && $d // +3 three runs
$a && ($b && $c) // +1 parentheses are skipped, not treated as a boundary
$a && !($b && $c) // +2 a negation is not a logical expression, so it ends the run

Grouping alone does not start a new run, which is why !A && (B || C) && D costs 3: reading it, you switch operator mode three times.

Nesting compounds across function boundaries

Section titled “Nesting compounds across function boundaries”

A closure scores nothing itself but raises the nesting level, and its cost lands on the unit that contains it:

function schedule() {
const run = () => { // +0 but nesting level is now 1
if (ready) { // +2 nesting=1
}
}
} // scores 2

The practical consequence, and the reason it matters most in JavaScript:

if (a) { for (const x of xs) { xs.forEach(item => { if (b) { … } }); } }

That is 7 for one function, visibly a pyramid. Scoring each function independently from zero would report two easy functions at 3 and 1, and callback pyramids would cost nothing.

A function-like is a scoring unit when no other function-like encloses it in the same file. That includes closures: a PHP routes file made of Route::get(..., function () {}) calls scores one unit per route, exactly as its Express equivalent does. Anything nested inside rolls up.

A declaration without a body, such as an abstract or interface method or a TypeScript signature, is not a unit. Whatever is left over at file scope (procedural code, templates, route tables, module-level bootstrap, the values of a configuration object) is scored as a single <toplevel> unit per file, and reported only when it scores above zero.

A unit is reported on its signature line, below any #[Attribute] or @decorator. The <toplevel> unit is reported on line 1, and a suppression marker for it lives in the comment block at the top of the file, behind the open tag or shebang.

A .vue file is the exception to both. Only its script blocks are parsed, so <toplevel> is reported at the start of the first one rather than line 1, and a marker above <template> is outside every parsed region and does nothing at all. Put it at the top of a script block; a marker in any block suppresses the one <toplevel> the component reports.

Units are named from wherever they are bound, since most closures are anonymous where they are written:

TypeScript Unit key
function parse() {} parse
const handler = () => {} handler
class C { method() {} } C::method
class F { field = () => {} } F::field
const api = { onClick() {} } api::onClick
export default function () {} default
const useCart = defineStore('cart', () => {}) useCart
app.get('/x', (req, res) => {}) app.get#1
anything else <anonymous>
PHP Unit key
function parse() {} parse
$handler = function () {} handler
$this->handler = function () {} $this->handler
class C { public function m() {} } C::m
$api = ['onClick' => function () {}] onClick
$handler = Closure::fromCallable(function () {}) handler
Route::get('/x', function () {}) Route::get#1
array_map(fn ($x) => $x, $xs) array_map#0
anything else <anonymous>

A factory or wrapper call hands its own binding to the callback, so a Pinia store or a React.memo(...) component is named after the thing it is assigned to. Only a lone callable argument is unwrapped, meaning a call that is nobody’s value, such as app.get('/x', fn) or describe('…', fn), keeps a positional key of <callee>#<zero-based argument index>.

Keys never contain line numbers, so editing above a function does not invalidate its baseline entry. Where two positional or anonymous keys collide, the later one gains a ~2 suffix.

PHP

  • match (8.0) is treated as switch: one increment for the whole expression.
  • and / or normalise onto && / || for run-counting; xor is its own operator.
  • elseif and else if score identically, despite different parse shapes.
  • break N / continue N are PHP’s analogue of the labelled break.
  • Recursion is detected through direct syntactic self-reference (f(), $this->f(), self::f(), static::f()). Dispatch through a variable needs symbol resolution and is not guessed at.

JavaScript and TypeScript

  • ??, ?., and &&= / ||= / ??= are shorthand and cost nothing.
  • in and instanceof are comparisons, not flow breaks.
  • JSX short-circuit rendering {cond && <X/>} costs +1, exactly as {cond ? <X/> : null} does.
  • for…of and for…in are one node kind in the grammar and score the same.
  • Bodyless declarations (method_signature, abstract_method_signature, function_signature, and the various type-level signatures) are not units.
  • .js is parsed with the TypeScript grammar. Flow-annotated .js will misparse.
  • super.f() and ClassName.f() count as self-reference for recursion, as this.f() does.

The rules above are bonsai-lint’s own. Nothing here defers to an outside authority, so what keeps them stable is the fixture suite rather than a document: the cases under crates/bonsai-lang-php/tests/ and crates/bonsai-lang-ts/tests/ carry their expected totals inline, and a scoring change that moves a number fails a test instead of shipping quietly.

Two of those cases are worth reading, because boolean runs are where implementations of a readability metric most often part company. a && (b || c) || d scores 2: parentheses are transparent to a run, so the reader switches operator mode twice. a && b || foo(b && c) scores 3: the argument’s && is its own run, not a continuation of the one outside the call. Everything the increment table claims about runs follows from those two readings.

Where a genuine judgement call exists, this documentation states which way bonsai-lint went and why, rather than leaving it implicit: else if taking a flat increment, a loop header sitting at the loop’s own level, a closure rolling its cost into the unit that contains it, and code at file scope being scored rather than skipped.