Skip to content

Multi-language cognitive complexity linter

bonsai-lint

Find the code that's hard to read.

In seconds, in one project or a whole monorepo. One static binary that reads JavaScript, TypeScript, Vue, Python, Java, PHP and Go, with no runtime or toolchain for any of them 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 programming-language logos, with a flag planted in its pot, a language mascot and a steaming coffee cup beside it, pruning shears leaning on it and trimmed leaves scattered below
Terminal window
npx bonsai-lint src/

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

One language or all of them

Point it at any project with nothing to configure. A mixed codebase scores in one pass on one metric, and the same logic gets the same number in every language. That is tested.

No runtime, no plugins, no conflicts

Nothing to wire into each language’s own linter and no plugin versions to keep in step.

Never executes your code

Syntax only: no autoloader, reflection or module resolution, so untrusted source is safe to scan.

Callbacks compound

A closure in a loop in a condition scores at its real depth, so a callback pyramid is one hard function, not several easy ones.

Sees the code other tools miss

Scripts, templates, route files and module-level setup are scored too. On a legacy codebase that is often where the worst of it hides.

Adoptable on day one

Baseline existing findings and fail only on regressions.

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

Terminal window
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

Terminal window
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

Terminal window
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
}
})
}
}
Terminal window
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 in 0.77s
a JavaScript, TypeScript and PHP monorepo, in one pass
1.9M lines per second
the median of that monorepo, 1.51M lines of Python, 2.29M of Java and 2.85M of Go
2 MB download
about 8 MB installed, every language built in
0 runtimes
no language runtime or toolchain, 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

In GitHub Actions, --format github annotates each finding on its line of the diff, and fails exactly when the text report does. The CI guide has the one-step workflow.

A pull request’s changed TypeScript file with two annotations on one line: a bonsai-lint breach as a check failure, and a warning that its suppression marker needs a reason