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 / elif, else |
+1 flat | yes |
else: on a Python loop or try |
+1 flat | its body sits where the construct’s own body does |
switch, match, select |
+1 +nesting (not per arm) | yes |
for, foreach, for…of, for…in, while, do |
+1 +nesting | yes |
catch, except |
+1 +nesting | yes |
try, finally, synchronized, with |
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, lambda, nested function, anonymous class method, comprehension | none | yes |
| Class, interface, trait, enum, record, 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, a TypeScript signature, a
Go function implemented in assembly or a Python def whose body is only ..., 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 scores its body, so its own default parameter values are not scored in any language,
neither in the unit nor in the file’s <toplevel>: def f(x=1 if flag else 2) and
function f(x = a ? 1 : 2) {} both score 0. A nested function’s defaults are part of the
enclosing unit, one level deeper like the rest of the nested function, so either f declared
inside outer scores outer 2. Logic in a default is rare, and where it belongs differs by
language: Python evaluates a default when the def runs, TypeScript when the call does. So it
waits for one decision across every language.
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 shebang, the package line in Java or Go, or PHP’s
open tag, and above a Python module docstring.
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.
The JSON report also gives the line a unit ends on, usually its closing brace. A Python body ends
on its last statement, or on a comment below it indented at least as deep as the body.
<toplevel> ends on the file’s last line, or in a .vue file where its last script block does.
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 |
const config = (() => {})() |
config |
app.get('/x', (req, res) => {}) |
app.get#1 |
| anything else | <anonymous> |
| Python | Unit key |
|---|---|
def process(order): |
process |
async def fetch(url): |
fetch |
class OrderService: holding def process(self, o): |
OrderService::process |
class Outer: holding class Inner: holding def m(self): |
Outer::Inner::m |
@property def total(self), then @total.setter def total(self, v) |
Cart::total, Cart::total.setter |
@overload def parse(x: int) -> int: ..., or any body of only ... |
not a unit |
handler = lambda e: … |
handler |
class C: holding key = lambda self: … |
C::key |
items: list = field(default_factory=lambda: []) in class C: |
C::items |
atexit.register(lambda: …) |
atexit.register#0 |
| anything else | <anonymous> |
A property’s getter, setter and deleter redefine one name on purpose, so the accessor a decorator
declares, @total.setter, joins the key after a dot, and each accessor is baselined apart.
Python has no other overloading. A def redefined under if/else, or several
functools.singledispatch implementations all named _, share one key.
| Java | Unit key |
|---|---|
class OrderService { void process(Order o, User u) {} } |
OrderService::process(Order, User) |
<T> void put(@NonNull java.util.Map<K, V> m, T[] xs, String... rest) |
C::put(Map, T[], String...) |
class Point { Point(int x, int y) {} } |
Point::Point(int, int) |
a compact constructor in record Point(int x, int y) |
Point::Point(int, int) |
class Outer { class Inner { void m() {} } } |
Outer::Inner::m() |
enum Op { PLUS { int apply(int a, int b) {} } } |
Op::PLUS::apply(int, int) |
static final Comparator<S> CMP = new Comparator<>() { public int compare(S a, S b) {} }; |
C::CMP::compare(S, S) |
private final Runnable handler = () -> {}; |
C::handler |
static final Supplier<X> S = memoize(() -> {}); |
C::S |
static { … }, and a second one |
C::<static>, C::<static>~2 |
void main() {} in a compact source file |
main() |
| anything else | <anonymous> |
Java overloads freely, so a method or constructor key carries its parameter types. Each is the
type’s simple name, with type arguments and annotations dropped. java.util.List<String> and an
imported List<String> therefore key alike, and a key survives reordering and adding overloads,
changing only when a parameter type does. Two overloads whose types differ only by package, such
as java.util.Date and java.sql.Date, share a key.
| 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 |
$config = (function () {})() |
config |
Route::get('/x', function () {}) |
Route::get#1 |
array_map(fn ($x) => $x, $xs) |
array_map#0 |
| anything else | <anonymous> |
| Go | Unit key |
|---|---|
func parse() {} |
parse |
func (s *Stack[T]) Push(v T) {} |
Stack::Push |
var handler = func() {} |
handler |
var routes = map[string]func(){"list": func() {}} |
list |
var handler = wrap(func() {}) |
handler |
var cfg = func() T { … }() |
cfg |
var _ = register(func() {}) |
register#0 |
a second func init() in the same file |
init~2 |
| anything else | <anonymous> |
A Go method is declared beside its type rather than inside it, so the receiver supplies the
container: pointer and type parameters are dropped, and the key does not change when the receiver
switches between pointer and value. init and _ may be declared any number of times, so they
are told apart like positional keys, and a _ binding names nothing.
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”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.
Python
-
elifscores exactly aselse if. Anifalone underelse:is nested code, because Python spells a chainelif. -
An
else:on afor,whileortrycosts +1, like anyelse: it runs only when the loop did not break, or nothing was raised, and the reader has to track that. Its body sits where the construct’s own body does, one level inside a loop and at atry’s own depth. -
matchcosts one increment for the whole statement, and its cases nest. Acase … ifguard costs only its operators. -
Each
except,except*included, costs +1 +nesting, and the exception it names is header, like a loop’s.try,finallyandwithare free and do not nest. -
andandornormalise onto&&and||, andnotends a run as!does.&,|and^are bitwise operators and free. -
A conditional expression,
a if c else b, costs +1 +nesting like a ternary. The grammar leaves its condition unfielded between the two branches; it is header all the same, and does not nest. -
A comprehension or generator expression costs nothing but raises nesting like a closure. It has its own scope, and it is what JavaScript writes as a
.filter().map()chain, which scores that way. A ternary or operator inside it still counts. -
A lambda and a nested def raise nesting and roll up wherever they sit, and so do the methods of a class defined inside a function. A decorator factory, a function holding only a nested function and its
return, is not exempt: the same shape nests in every other language. -
Python has no labels, so
breakandcontinueare always free.assert,raise,yield,await,del,global,:=and decorators are free. -
Recursion:
- In a module function, a bare
f()counts. - In a method,
self.m(),cls.m()andC.m()count. For a nested class the class is reached by its full path,Outer.Inner.m(), because its own name is not in scope inside its methods. - A bare
m()orInner.m()inside a method names a global, not the method, andsuper().m()runs the parent’s implementation. None of them is recursion.
- In a module function, a bare
-
Module code, class bodies and decorator arguments run on import, so they are the file’s
<toplevel>. -
A docstring is a string, not a comment, so it cannot carry a suppression marker.
-
A file whose comments before the first line of code say both “generated” and “do not edit” is neither scored nor counted. That is how protobuf, gRPC and Thrift output marks itself. A Django migration says only “Generated by Django”, because it is meant to be edited.
-
Grammar gaps. These parse with an error:
- a slice in a parameter annotation,
def f(x: a[:, 0])(the same slice in an expression parses); - several starred items in one subscript,
Union[*args, *rest]; - type parameter defaults,
def f[T = int]()(Python 3.13); - a bracketed continuation line indented less than its block.
The code around the error still scores. In a class whose body holds one, the methods after it may lose the class from their keys.
- a slice in a parameter annotation,
Java
-
A
switchcosts one increment in statement and expression form, withcase …:groups or->rules alike.yieldis free, and awhenguard costs only its operators. -
Each
catchcosts +1 +nesting, and a multi-catchcatch (A | B e)is one clause.try, try-with-resources andfinallyare free. -
A labelled
breakorcontinuecosts +1; an unlabelled jump is free. -
Only
&&and||form operator runs.&,|and^are free: on booleans they are logical, but syntax alone cannot tell them from the bitwise operators. -
synchronizedneither costs nor nests.assert,throw,instanceofand its patterns, and method references are free. -
Lambdas and the methods of anonymous and local classes raise nesting and roll up into the method that holds them.
-
A
static { … }block is a unit of its own. Field initialisers and instance initializer blocks are the file’s<toplevel>. -
Recursion:
- What counts:
m(),this.m(),C.m()andC.this.m(). super.m()runs the parent’s implementation and is not recursion.- Constructor chaining through
this(…)orsuper(…)is not a call. - Overloads share a name, so a call reaches the method only when its argument count fits,
where varargs accepts any number beyond the fixed parameters.
process(o) { process(o, u); }is a delegating overload, not recursion. - An overload with as many parameters still reads as the method itself.
- A call through another object of the same type, such as
left.count(), would need type information and is not counted.
- What counts:
-
A file whose comments before the first line of code say both “generated” and “do not edit” is neither scored nor counted. That is how protobuf, Thrift, Avro and JavaCC output marks itself.
-
Grammar gaps. The grammar predates some Java 21–25 syntax, and these parse with an error:
- several patterns in one
caselabel; case final;- a qualified record pattern;
import module;- statements before
super(…)in a constructor; 1__000.
The code around the error still scores, but the construct itself may not. A type annotation before varargs,
String @Nullable ... args, parses with an error too, but its method keeps both its score and its key,setHosts(String...). - several patterns in one
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.
Go
foris the only loop, and its three-clause, condition-only, infinite andrangeforms are one node kind that scores the same.- An expression switch, a type switch and
selecteach cost one increment for the whole statement.fallthroughis free. - A labelled
breakorcontinue, andgoto, cost +1; an unlabelled jump is free. - The initializer in
if err := f(); err != nilis header, scored at the statement’s own depth like the condition beside it. The same holds for aswitchinitializer. - There is no
elsenode in the grammar, but anelse ifstill scores flat and a plainelseblock still nests, as in TypeScript and PHP. defer,goandrecoverare free; a function literal they launch raises nesting like any closure.- Recursion is a call through the receiver,
s.Push()or(*s).Push(), or a method expression such as(*Stack).Push(s)orStack[T].Push(s). A method cannot be called without its receiver, so a barePush()inside it names a free function or a builtin and is not recursion, and neither isstrings.Splitinsidefunc Split. - A call through any other value, even one of the same type such as
c.Walk()over a node’s children, would need type information to recognise and is not counted. - A generic function calling itself counts whether its type arguments are inferred,
Walk(x), or written out,Walk[T](x), even when they differ from its own: it is still a call to its own name. - A file with a
// Code generated … DO NOT EDIT.line before itspackageclause is neither scored nor counted.
Choices that move the numbers
Section titled “Choices that move the numbers”Implementations of cognitive complexity make different choices in a handful of places, and those choices change the numbers. Here is every one bonsai-lint makes, so a score that doesn’t match another tool has an explanation rather than a mystery.
The first is the one that moves numbers: bonsai-lint scores a nested function at the depth it sits at and rolls it into the unit that contains it, where most implementations score each function from zero on its own. Per-function says how hard each piece is in isolation; rolling up says how hard the whole thing is to read in place.
| Elsewhere | bonsai-lint | |
|---|---|---|
| Closure inside a function | scored separately, from zero | carries the nesting it sits at |
??, a?.b |
+1 | free |
JSX {cond && <X/>} |
exempt | +1, the same as the equivalent ternary |
const x = a || [] |
exempt | +1 |
| Code outside any function | not scored | scored as <toplevel> |
An if inside a plain else block |
no deeper than the else |
one level deeper, like any branch body |
a && (b || c) && d |
2, the group counted as its own run | 3, grouping is transparent |
a && b || c && d |
2, each operator counted once | 3, each switch of operator starts a run |
A later link of an else if chain |
one level deeper per link | one level below its if, like the first |
A loop or switch header |
one level deeper | at the construct’s own depth |
| A method calling itself through its receiver | not recursion | +1 |
A builtin sharing a method’s name, append() inside append |
+1, as recursion | free |
A Python comprehension, [x for x in xs if x] |
each for and if clause +1, or ignored |
free, but nests like the callback chain it replaces |
A Python loop’s or try’s else: |
free | +1, like any else |
A Python match |
free | +1, like a switch |
f(key=a or b), (x or "") + y, f"{a and b}" |
not counted | +1, wherever the operator sits |
A function holding only a nested function and its return |
the inner one keeps its level | one level deeper, like any closure |
A nested function under an if or with, or a method of a local class |
no deeper than the code around it | one level deeper, like any closure |
A bare m() inside Python method m |
+1, as recursion | free: the name is a global |
| A Python function recursing twice | +1 once, and not through self |
+1 per call, through self, cls or its class |
| A local closure sharing its function’s name | resolved to the local | +1 per call, matched by name |
| A Java overload taking as many arguments | resolved by type, not recursion | +1, matched by name and argument count |
A call through another instance of the same type, left.count() |
+1, as recursion | free |
The last three rows are limits rather than preferences. Recursion is recognised by syntax alone, with no scope or type analysis. So a closure that shadows its enclosing function’s name reads as a self-call in every language, an overload with the same number of parameters reads as the method itself, and a call through another object of the same type is not recognised at all.
Every chain body sits one level below its if because else if is a flat increment. Nesting each
later link deeper would charge a long chain for its length, which is exactly what the flat
increment exists to avoid. A run that returns to an operator after switching away costs again,
as the counting rule states: a && b || c && d switches operator twice, so it is three runs.
Some implementations skip whole positions, and never look inside a keyword argument, an operand of
+, a subscript or an f-string, so an operator, a ternary or a comprehension there costs nothing.
bonsai-lint counts a flow-breaker wherever it sits.
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 each language
crate’s tests/ directory (crates/bonsai-lang-*/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.