Skip to content

Cognitive complexity, for PHP · JavaScript · TypeScript · Vue

bonsai-lint

Find the code that's hard to read.

In seconds, in one project or a whole monorepo. One static binary, with no PHP runtime, no Node runtime and nothing added to your project.

Bonsai: the art of keeping a tree small enough to take in at a glance. Same idea, applied to your syntax trees.

A bonsai tree whose leaves are JavaScript, TypeScript and PHP logos, with pruning shears beside the pot and trimmed leaves scattered below
Terminal window
npx bonsai-lint --over 15 src/

Every option, and what each one costs you, on the install page.

One language, or all of them

Point it at a PHP project and it is a PHP linter; point it at a TypeScript one and it is a TypeScript linter. Point it at both and they score on one metric in one pass.

No runtime, no plugins, no conflicts

Nothing to wire into a PHPStan or ESLint setup, no plugin versions to keep in step, no Composer entry. A 6 MB binary, or npx and install nothing at all.

Never executes your code

Syntax-only: no autoloader, no reflection, no module resolution. Safe to point at third-party or untrusted source.

Complexity compounds through callbacks

A closure inside a loop inside a condition is scored at the depth it actually sits at, so a callback pyramid shows up as one hard function instead of several innocent-looking ones.

Sees the code other tools miss

Procedural scripts, templates, route files and module-level initialisation are scored too. On a legacy codebase that is often where the worst of it has been hiding.

Adoptable on day one

Baseline your existing violations and gate on regressions, instead of being told to fix hundreds of functions before you can turn it on.

Worst first. Score, location, and the qualified name of the unit. That is the whole format. A clean run prints nothing and exits 0.

69 packages/billing/src/invoice-mapper.service.ts:47 InvoiceMapperService::mapLineItems
25 services/api/src/Controller/CheckoutController.php:207 CheckoutController::applyDiscounts
22 packages/web/src/parser/lexer.js:19 Lexer

The default threshold is 15, a conventional starting point. Change it for a run with --over, or for a project in bonsai-lint.toml, because a threshold is a claim about what your team will tolerate and that claim is yours to make.

backend/Orders.php

foreach ($orders as $order) { // +1
if ($order->isActive()) { // +2
array_map(function ($item) { // +0, nesting 3
if ($item->qty > 0) { // +4
return $item->qty > 10 ? 'bulk' : 'single'; // +5
}
}, $order->items());
}
}

frontend/orders.ts

for (const order of orders) { // +1
if (order.isActive()) { // +2
order.items().map((item) => { // +0, nesting 3
if (item.qty > 0) { // +4
return item.qty > 10 ? 'bulk' : 'single' // +5
}
})
}
}
12 backend/Orders.php:3 OrderRepository::syncLineItems
12 frontend/orders.ts:1 syncLineItems

Identical logic, identical number. That is what makes one threshold meaningful across a monorepo, and it is a tested property, not an aspiration.

1.26M lines
PHP, JavaScript and TypeScript, in one pass
0.77s to scan them
roughly 1.6 million lines per second
6.3 MB binary
about 1 MB to download, every language built in
0 runtimes
no PHP, no Node, nothing added to your project

Files are read, parsed and scored in parallel, and the report is assembled afterwards rather than streamed as results arrive, so two runs of the same commit are byte-for-byte identical however many workers ran. A diff between runs is always a real change.

The VS Code extension analyses the buffer as you type, and agrees with CI by design: same binary, same config, same baselines.

A cognitive complexity warning underlining a TypeScript function name, with the Problems panel showing one diagnostic