ryckakas/bonsai-lint: Multi-language cognitive complexity linter, as a single static binary - ryckakas/bonsai-lint

github.com · 2026-09-30 · by Long Nguyen ·github
Multi-language cognitive complexity linter, as a single static binary - ryckakas/bonsai-lint

ryckakas/bonsai-lint

Multi-language cognitive complexity linter, as a single static binary - ryckakas/bonsai-lint

7 stars · 0 forks

README

bonsai-lint

Find the code that's hard to read, in seconds, in one project or a whole monorepo. Bonsai: the art of keeping a tree small enough to take in at a glance. Same idea, applied to your syntax trees. A multi-language cognitive complexity linter written in Rust. One static binary that currently reads JavaScript, TypeScript, Vue single-file components, Python, Java, PHP and Go. Use it for any one of them, or all at once. No runtime or toolchain for any of them, and nothing to wire into your project. Scans 1.26 million lines in 0.8 seconds. The official site is bonsai.kauneckas.dev: install and editor guides, and a playground that scores code in your browser.

Why this one

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.

Cognitive complexity measures how hard code is to read, where cyclomatic complexity measures how hard it is to test. A switch with twenty arms is cyclomatically awful and cognitively fine; three nested ifs are the reverse. Install npx bonsai-lint src/ # run it without installing anything npm install -D bonsai-lint # or pin it in the project uvx bonsai-lint src/ # the same, for a Python project pip install bonsai-lint # or: uv tool install / pipx install bonsai-lint composer require --dev bonsai-lint/bonsai-lint # in a PHP project brew install ryckakas/tap/bonsai-lint # macOS and Linux go install bonsai.kauneckas.dev/bonsai-lint@latest cargo install bonsai-lint # from source curl -LsSf https://github.com/ryckakas/bonsai-lint/releases/latest/download/bonsai-lint-installer.sh | sh

How each install source works: npm, Linux builds, Go, PyPI and Composer The npm package fetches the prebuilt binary for your platform on install. Nothing is compiled, and Node only launches it. The analysis itself is pure Rust. Linux has two builds. One links glibc 2.35 or newer. The other is static and runs on any Linux, which covers Alpine and other musl systems as well as older glibc. The installer script, the npm package and the Go module each pick the right one for you. The PyPI wheels and the Composer package always use the static one. In a Go module, go get -tool bonsai.kauneckas.dev/bonsai-lint@latest pins it in go.mod, and go tool bonsai-lint runs it (Go 1.24+). The Go module is a launcher with no dependencies, at the same version as the CLI. The first run of each version downloads that release's binary, checks it against a checksum recorded in the module, and caches it. The platforms and settings are in bonsai-lint-launcher. From PyPI, pip install bonsai-lint, uv tool install bonsai-lint or pipx install bonsai-lint puts the binary on your PATH, and uvx bonsai-lint runs it without installing anything. Each wheel holds the release's own binary, with no Python code around it: macOS on Apple silicon and Intel, Linux on x86_64 and arm64 (glibc 2.17 or newer, or musl), and Windows on x64. There is no source distribution, so any other platform gets pip's "no matching distribution". That includes an ARM64 Python on Windows, where an x64 Python works under emulation, and so does cargo install bonsai-lint. In a PHP project, composer require --dev bonsai-lint/bonsai-lint adds it and vendor/bin/bonsai-lint runs it. It needs PHP 7.4 or newer, and the first run fetches the prebuilt binary for your platform.

As a pre-commit hook, from v0.4.2: repos: - repo: https://github.com/ryckakas/bonsai-lint rev: v0.4.2 hooks: - id: bonsai-lint # args: [--over, "10"] pre-commit installs the matching wheel itself and runs the hook on the staged files it can score. The hook judges each file exactly as bonsai-lint run from the repository root does, so a bonsai-lint.toml below the root counts only when the root config declares it as a domain. A commit touching only files it skips, such as generated code, passes. pre-commit autoupdate moves rev to the latest release. There is no Maven or Gradle plugin yet. On a Java project, use Homebrew, the installer script or npx bonsai-lint. Use it bonsai-lint src/ # fail on anything above 15 bonsai-lint --over 10 src/ # stricter; `--over php=10,typescript=20` per language bonsai-lint --all src/ # every unit, ranked bonsai-lint --format json src/ # for editors and scripts bonsai-lint --write-baseline src/ # record today's findings, exit 0 bonsai-lint --lang php src/ # one language only; `--help` lists the ids

The rest of the flags: monorepos, CI and editors bonsai-lint --domain web # one declared domain only bonsai-lint --baseline PATH . # one baseline file for the whole repo, wherever you choose bonsai-lint --strict-baseline . # fail while the baseline accepts more than the code needs bonsai-lint --format github . # annotate the pull request from GitHub Actions bonsai-lint --config packages/web src/ # discover config from here, not from the first path bonsai-lint --no-toplevel src/ # skip code outside any function bonsai-lint --stdin --stdin-path src/a.php < buffer # score an unsaved buffer as that file bonsai-lint --jobs 4 . # cap the workers; 0 or absent uses every core bonsai-lint --allow-no-files a.d.ts gen.go # pass when none of the paths can be scored

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 same run as JSON bonsai-lint --format json . { "thresholds": { "go": 15, "java": 15, "php": 15, "python": 15, "typescript": 15, "vue": 15 }, "breaches": 3, "findings": [ { "path": "packages/billing/src/invoice-mapper.service.ts", "line": 47, "end_line": 112, "name": "InvoiceMapperService::mapLineItems", "score": 69, "language": "typescript", "domain": "root" }, { "path": "services/api/src/Controller/CheckoutController.php", "line": 207, "end_line": 268, "name": "CheckoutController::applyDiscounts", "score": 25, "language": "php", "domain": "root" }, { "path": "packages/web/src/parser/lexer.js", "line": 19, "end_line": 140, "name": "Lexer", "score": 22, "language": "typescript", "domain": "root" } ], "loose_entries": [] } findings is already ranked, worst first, so a consumer does not have to sort it. thresholds is keyed by language id and reports what the scan actually applied, which is the domain's threshold rather than the root's when the scan was scoped to one. breaches counts the findings over that threshold; with --all the array also carries everything under it, and breaches still counts only the ones that failed. loose_entries lists the baseline entries looser than the code, the ones named on stderr. Each has the domain whose baseline holds it (null for a --baseline PATH file), the path and unit it is keyed by, its recorded score, a reason (lower, not_over_threshold, suppressed or unmatched), the unit's current score and, for not_over_threshold, its threshold. strict says whether the entry's baseline is strict. The exit code follows breaches and the strict entries. A scan that judged no baseline lists none. domain names the domain a file resolved to — root when there is no bonsai-lint.toml declaring any — and path is always forward-slashed, so a report generated on Windows compares against one generated in CI. line is the unit's signature and end_line its last line.

In GitHub Actions --format github prints each breach as a workflow command, so it shows as an annotation on the pull request, and fails exactly when the text report does. A marker without a reason warns on its unit, a loose baseline entry is filed on its baseline file, and config warnings and read errors on the run. Paths are made relative to GITHUB_WORKSPACE, so the step can run from any directory; a file outside it is annotated on the run. A repository checked out into a subdirectory with path: is annotated under that subdirectory, which GitHub cannot place. GitHub annotates at most 10 errors per step; the log lists every one.

A clean run prints nothing and exits 0. Exit 1 means a breach, a strict baseline accepting more than the code needs, or that the scan was untrustworthy, because a path could not be read or matched no supported file. A gate that cannot read what it was pointed at must not report success.

Supported languages and extensions

Extensions Parsed as Language id

.js, .mjs, .cjs, .jsx, .tsx TypeScript with JSX typescript

.ts, .mts, .cts TypeScript typescript

.vue the <script> blocks only vue

.py, .pyw Python python

.java Java java

.php, .phtml PHP php

.go Go go

.min.js, .min.mjs, *.min.cjs skipped, generated output

.d.ts, .d.mts, *.d.cts skipped, signatures only

.pyi not scanned, typing stubs

.py whose header says "generated" and "do not edit" skipped, generated output

.java whose header says "generated" and "do not edit" skipped, generated output

.go headed // Code generated … DO NOT EDIT. skipped, generated output

The language id is what --lang, --over LANG=N and a [section] in the config take, so typescript covers every JavaScript and TypeScript file however it is parsed. An id that is not one of these is an error, not a silent no-op. .js is parsed with the TypeScript grammar, which accepts a superset of JavaScript. Flow annotations are the one thing this misparses. A declaration file and a minified one are both skipped outright, for opposite reasons: the first holds only signatures so every unit scores zero, and the second is generated output whose whole body rolls up into one unit nobody will refactor. The skipped names are whole suffixes, listed by the TypeScript and TSX descriptors' unscored_suffixes, so app.mini.js, jasmine.js and a file simply called min.js are all still scanned. A .vue file scores its <script> and <script setup> blocks, with lang="ts" choosing the TypeScript grammar and anything else (including no lang) choosing the JSX-capable one, which is the grammar .js already uses. Each block is parsed on its own, and their file-level code is added together into the one <toplevel> a component reports. Reported lines are lines in the .vue file, so editing a template moves a finding without changing its baseline key.

Construct Scored

<script> and <script setup>, any lang yes, as vue

a render() function written in a script block yes, as vue

JSX inside <script lang="tsx"> yes, as vue

<template>: v-if, v-for, {{ }}, @click="a && b()" no

<style>, and custom blocks such as <docs> or <i18n> no

<script src="./logic.ts"> no, but logic.ts is scanned on its own, as typescript

Two consequences are worth knowing:

The template is deliberately not scored. A v-if chain is real branching, but the the metric is defined over script code, and counting a template would make a component incomparable with the same logic written in TypeScript. A render() function is scored, because it is ordinary script code, so moving logic out of a template and into render() makes it visible, and a component that never had a template was never hidden. A src= block belongs to the file it points at. That file is scored as typescript, so a [vue] threshold does not reach it.

A Python method is keyed by its class, OrderService::process. A property's getter, setter and deleter redefine one name on purpose, so the accessor a decorator declares joins the key: Cart::total, Cart::total.setter and Cart::total.deleter are baselined apart. A def whose body is only ..., such as an @overload stub or a protocol method, declares a signature and is not a unit. A .py file is skipped as generated by the same header rule as Java below, which covers protobuf, gRPC and Thrift output. A Django migration says only "Generated by Django", because it is meant to be edited, so it stays scored. Java overloads freely, so a Java method or constructor is keyed by its parameter types as well as its name: OrderService::process(Order, User). Each type is its simple name, without type arguments or annotations, so two overloads are baselined apart and switching between an import and a qualified name never re-keys one. Java has no single generated-code convention, so a .java file is skipped when the comments before its first line of code say both "generated" and "do not edit". That covers protobuf, Thrift, Avro and JavaCC output. Most generated sources sit in a gitignored target/ or build/ directory and are never scanned in the first place. Go marks generated code in the file rather than its name, so a .go file is read first and then turned away if a // Code generated … DO NOT EDIT. line comes before its package clause, which is the convention the Go toolchain itself follows. Such a file is neither scored nor counted, on disk and through --stdin alike. vendor/ and testdata/ get no special treatment: if a repository commits them, exclude = ["vendor/", "/testdata/**"] keeps them out. A Go method is keyed by its receiver type, so func (s *Stack[T]) Push() reports as Stack::Push and keeps its baseline entry when the receiver switches between pointer and value.

The same code scores the same in every language foreach ($orders as $order) { // +1 if ($order->isActive()) { // +2 array_map(function ($item) { // +0, nesting is now 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.

Configure it, or don't Nothing on disk is required. Defaults are a threshold of 15 for every language, .gitignore respected, top-level code scored. One bonsai-lint.toml at the repository root covers a normal project: threshold = 15 exclude = ["vendor/", "/*.generated.ts"]

[typescript] threshold = 20

Every configuration key

Key Default Meaning

threshold 15 Fail above this score. A section named after a language id, such as [typescript], overrides it per language.

exclude [] Globs, relative to the config's own directory. stops at / and * crosses it, as in .gitignore.

toplevel true Score code outside any function as <toplevel>.

baseline .bonsai-lint-baseline.json Where this directory's baseline lives, relative to it.

strict-baseline false Fail when the baseline has an entry looser than the code, as --strict-baseline does everywhere. A domain without it takes the root's, and with --baseline PATH the root's decides.

domains none Root config only: globs naming directories that own their own config and baseline.

name its path from the root, such as packages/web A domain's name in output and for --domain. Two domains cannot share one.

A misspelt key is an error rather than a silent default, named with a suggestion when one is close, and so is a negated glob: !pattern is not supported.

Monorepos: per-team thresholds and baselines Add domains and each team owns its own thresholds and its own baseline, without editing a shared file. The root declares where domains may live, so a stray config cannot quietly create one.

bonsai-lint.toml at the repository root

domains = ["packages/", "services/"] threshold = 15

packages/web/bonsai-lint.toml

name = "web" threshold = 20 packages/web/.bonsai-lint-baseline.json # each domain keeps its own services/billing/.bonsai-lint-baseline.json

--write-baseline then writes one file per domain and prints what it wrote, or says that there was nothing to record. Baseline keys are relative to the domain root, so they survive being checked out anywhere, on any platform. A domain's own exclude is relative to its root too; the root's applies everywhere. --baseline PATH keeps every domain in one file of your choosing instead. Its keys are relative to the repository root, so two domains with a src/index.ts cannot collide. Which to use is your call: one file is simpler for a small repository, while per-domain files stay short and put each team's accepted debt in the directory that team owns. Wherever you point the CLI, the workspace is the same one. Discovery walks up from the first scanned path and takes the outermost bonsai-lint.toml that declares domains as the root, so bonsai-lint packages/web applies the same root excludes, inherited thresholds and domain list as bonsai-lint ., and so does an editor buffer. Without a domains declaration anywhere, the nearest config is the root. --config PATH starts the walk somewhere else.

Baselines: adopt it without fixing everything first bonsai-lint --write-baseline src/ Records everything currently above the threshold as accepted. Later runs fail only on scores that got worse, or on units the baseline has never seen. An unknown key is treated as a regression, never as an acceptance. A renamed function is reported rather than silently inheriting someone else's amnesty. Two units in one file that share a key, such as a def redefined under if/else, share one entry holding the higher score. A baseline also has to tighten, or it rots into a permanent exemption. A scan that covered the whole domain names every entry that rewriting the baseline would drop or lower: a unit that now scores less, one no longer over its threshold, one suppressed with a reason, and one that matched nothing because it was deleted or renamed. root: src/Checkout.php: Checkout::apply is baselined at 38 but scores 20 root: src/Legacy.php: Legacy::run is baselined at 21 but matched nothing in this scan

Those lines are warnings. With --strict-baseline, or strict-baseline = true, they fail the run, so an improvement has to be locked in: once nothing else fails, rewrite the baseline with --write-baseline over the whole workspace and the run's other flags. On a subset it would empty the rest of the baseline (roadmap). Raising a threshold, or passing --over above the one the baseline was written with, leaves entries unneeded too. A single file from a pre-commit hook, an editor buffer, --domain or --lang never judges a baseline, since it cannot see the entries whose units are gone. Without a bonsai-lint.toml, the directory holding the baseline is the project root, so bonsai-lint --write-baseline . followed by bonsai-lint src/Foo.php finds the same entries.

Suppressing a finding: with a reason, or not at all // bonsai-lint-ignore: hand-tuned state machine, splitting it hurts more than it helps function parse(string $input): Ast { / ... / } Works above the declaration, inside the docblock, or trailing the signature line, in every supported language. In Python it goes in a # comment above the def or its decorators: a docstring is a string inside the body, not a comment, so it cannot carry a marker. It works for a bound function too, such as const handler = () => {}, Python's handler = lambda e: …, a

source

github.com · 11 images · 143 links

Comments (0)

No comments yet