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 runGrouping 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 2The 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.
Language specifics
Section titled “Language specifics”PHP
match(8.0) is treated asswitch: one increment for the whole expression.and/ornormalise onto&&/||for run-counting;xoris its own operator.elseifandelse ifscore identically, despite different parse shapes.break N/continue Nare 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.inandinstanceofare comparisons, not flow breaks.- JSX short-circuit rendering
{cond && <X/>}costs +1, exactly as{cond ? <X/> : null}does. for…ofandfor…inare 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. .jsis parsed with the TypeScript grammar. Flow-annotated.jswill misparse.super.f()andClassName.f()count as self-reference for recursion, asthis.f()does.
How these rules are pinned
Section titled “How these rules are pinned”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.