Changelog
Notable changes per release, in the format of Keep a Changelog. This project follows Semantic Versioning. While it is pre-1.0, the minor version is the compatibility unit: a backwards-compatible change is a patch bump (0.3.0 → 0.3.1), and a breaking change to a library crate’s public API moves to the next minor (0.3.x → 0.4.0).
dist reads the section matching a tag and uses it as that release’s notes, so an entry here is
what a user reads on the GitHub release page.
0.5.0 - 2026-09-30
Section titled “0.5.0 - 2026-09-30”--strict-baseline, andstrict-baseline = trueinbonsai-lint.toml. A baseline entry looser than the code fails the run with exit 1, so the baseline tightens as the code improves instead of accepting the old score for good. The key can be set per domain, and a domain without it takes the root’s. Only a scan of a whole domain judges its baseline, so a pre-commit hook or an editor never fails on one.--format github, for GitHub Actions. Each breach becomes an annotation on its line of the pull request, a loose baseline entry one on the baseline file, and config warnings and read errors one on the run. It fails exactly when the text report does. Paths are made relative toGITHUB_WORKSPACE, so the step can run from any directory.
Changed
Section titled “Changed”- Baseline entries the code has outgrown are named, one line each. Besides entries that match
nothing, which used to be only counted, a scan of the whole domain now reports an entry whose
unit scores less than recorded, no longer scores over its threshold, or is suppressed with a
reason:
root: src/a.php: busy is baselined at 28 but scores 21. Without the new flag the exit code is unchanged, and the baseline format too, so existing baselines keep working. To silence the new lines, or before turning on strict mode, rewrite a baseline with--write-baselineover the whole workspace and the flags CI runs with. - The JSON report gains
loose_entries, always present and empty when there are none: the same entries, each with itsdomain,path,unit,recordedscore,reason, currentscore,thresholdand whether it isstrict. - Each finding in the JSON report gains
end_line, the unit’s last line. - For embedders of
bonsai-coreandbonsai-engine, and breaking, which is why this is 0.5.0 rather than 0.4.4:Baseline::loosereturns each loose entry as aLooseEntrywith aLoosenessreason, and replacesBaseline::unmatched.ConfigFileandDomaingainstrict_baseline, andReportgainsloose_entries, built from the newReportedLooseEntryandLooseReason.FindingandReportedFindinggainend_line, andBaseline::recordedreturns the score a baseline accepts for a unit.
- An unsaved file in a directory that does not exist yet now finds its domain like a saved
file would.
--stdin-pathused to be resolved through its parent directory alone, so a path whose parent was new kept a symlink or a Windows short name, and missed the domain root.
0.4.3 - 2026-09-27
Section titled “0.4.3 - 2026-09-27”- Installing through Composer.
composer require --dev bonsai-lint/bonsai-lintadds it to a PHP project, andvendor/bin/bonsai-lintruns it. The package is a launcher with no dependencies that needs PHP 7.4 or newer. The first run of each version downloads that release’s binary, checks it against a checksum recorded in the tagged package, and caches it insidevendor/. It honours the proxy variables the way Composer does, and never sends a GitHub or Composer token. It lives beside the Go launcher in bonsai-lint-launcher, the renamed bonsai-lint-go, and one tag publishes both.
0.4.2 - 2026-09-27
Section titled “0.4.2 - 2026-09-27”- Installing through PyPI.
pip install bonsai-lint,uv tool install bonsai-lintandpipx install bonsai-lintinstall the CLI, anduvx bonsai-lintruns it without installing. Each wheel is the release’s own binary, byte for byte, with no Python code around it: macOS on Apple silicon and Intel, Linux on x86_64 and arm64 through the static build (glibc 2.17 or newer, or musl), and Windows on x64. There is no source distribution, so other platforms, including an ARM64 Python on Windows, get pip’s “no matching distribution”. The wheels are published at every release under the same version, through PyPI trusted publishing with attestations. - A pre-commit hook.
- repo: https://github.com/ryckakas/bonsai-lintwithid: bonsai-lintinstalls the wheel and scores the staged files. It judges each file asbonsai-lintrun from the repository root does, and a commit touching only files it skips, such as generated code, passes. --allow-no-files. A run whose paths hold no file it can score exits 0 instead of failing with “no supported files found”. A missing path still fails the run.
0.4.1 - 2026-09-26
Section titled “0.4.1 - 2026-09-26”-
Python.
.pyand.pywfiles are scanned under a newpythonlanguage id, so--lang python,--over python=Nand a[python]section in the config all work..pyistubs are not scanned.- Keys. A method is keyed by its class,
OrderService::process. A property’s getter, setter and deleter share a name, so the accessor joins the key:Cart::total,Cart::total.setter,Cart::total.deleter, each baselined apart. A def whose body is only..., such as an@overloadstub or a protocol method, is not a unit. - Scoring.
elifscores aselse if. Anelse:on afor,whileortrycosts +1, like anyelse.matchcosts one increment and its cases nest. Eachexceptcosts one, whiletry,finallyandwithare free. A conditional expression costs like a ternary, and its condition does not nest. A comprehension is free but raises nesting like a closure, as the callback chain it replaces does in JavaScript. - Recursion. A module function reaches itself by its bare name, and a method through
self,clsor its class’s full path,Outer.Innerfor a nested class. A bare call inside a method names a global, andsuper()runs the parent’s implementation, so neither is recursion. - Units. Lambdas, nested defs, comprehensions and the methods of local classes roll up into
the enclosing function. Module code, class bodies and decorator arguments are the file’s
<toplevel>. - Built behind a
pythoncargo feature, on by default.
A file whose comments before the first line of code say both “generated” and “do not edit” is skipped, neither scored nor counted, on disk and through
--stdin. That covers protobuf, gRPC and Thrift output. A Django migration says only “Generated by Django”, so it stays scored.Upgrading a repository that contains
.pyfiles will report findings that were previously invisible. Runbonsai-lint --write-baseline .to adopt them.There is no PyPI package or pre-commit hook yet. On a Python project, install through Homebrew, the installer script or
npx bonsai-lint. - Keys. A method is keyed by its class,
Changed
Section titled “Changed”- For embedders of
bonsai-core:- An
elsethat no if chain claims now costs +1 on its own. Only Python’s loop andtryelse produce one, so no existing language’s scores change. Hooks::is_control_headeris a new provided method, defaulting tofalse. It names a header child that has no field, such as the condition in the middle of Python’sa if c else b.
- An
- When two units in one file still share a key, such as a Python def redefined under
if/else,--write-baselinerecords the higher of their scores, so an unchanged rerun passes. It kept the last one reported, the lower score, and the higher one failed every run after. The two now share one budget, so the lower one can grow up to the higher score unnoticed. - Two Java anonymous classes passed to one constructor, as in
new Dispatcher(new Runnable() { … }, new Runnable() { … }), no longer share a key. Both methods wereC::run(), so the baseline kept one score and an unchanged rerun failed on the other. A callback passed to a constructor now takes the constructor’s type and its argument position, as a method call’s arguments already did:C::new Dispatcher#0::run()andC::new Dispatcher#1::run(). Lambdas in the same place move from<anonymous>to the same form, so a baseline holding either needs--write-baselineonce.
0.4.0 - 2026-09-25
Section titled “0.4.0 - 2026-09-25”-
Java.
.javafiles are scanned under a newjavalanguage id, so--lang java,--over java=Nand a[java]section in the config all work.- Keys. Java overloads freely, so a 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. Two overloads are therefore baselined apart, and switching between an import and a qualified name never re-keys one. - Recursion. A call to the method’s own name counts only when its argument count fits, so
a delegating overload,
process(o) { process(o, user()); }, is not recursion. Neither issuper.process(), which runs the parent’s implementation. - Scoring. A
switchcosts one increment in statement and expression form, awhenguard costs only its operators, and eachcatchcosts one.synchronized,&,|and^are free. - Units. Lambdas and the methods of anonymous and local classes roll up into the enclosing
method. A
static { }block is a unit of its own,C::<static>. Field initialisers and instance initializer blocks are the file’s<toplevel>. - Built behind a
javacargo feature, on by default.
A file whose comments before the first line of code say both “generated” and “do not edit” is skipped, neither scored nor counted, on disk and through
--stdin. That is how protobuf, Thrift, Avro and JavaCC output marks itself.Upgrading a repository that contains
.javafiles will report findings that were previously invisible. Runbonsai-lint --write-baseline .to adopt them.There is no Maven or Gradle plugin yet. On a Java project, install through Homebrew, the installer script or
npx bonsai-lint. - Keys. Java overloads freely, so a method or constructor is keyed by its parameter types
as well as its name:
-
musl Linux builds. Static
x86_64-unknown-linux-muslandaarch64-unknown-linux-muslarchives ship beside the glibc ones, so Alpine and other musl systems get a prebuilt binary. They need no libc at all, so they also serve hosts whose glibc is older than 2.35, such as Debian 11, Amazon Linux 2 and RHEL 8. The installer script and the npm package pick them there on their own, and the Go launcher now does too instead of pointing atcargo install. musl’s allocator serialises threads, which made a parallel scan over 15 times slower than the glibc build, so these builds use mimalloc, which on arm64 brings them level with the glibc build.
Changed
Section titled “Changed”- For embedders of
bonsai-core, and breaking for code that buildsUnitNameorWalkCxwith a struct literal, which is why this is 0.4.0 rather than 0.3.1:UnitNamegains asignaturefield. It is joined onto the reported name but never matched against a call.UnitName::with_signaturesets it.Hooksgains two methods with defaults:unit_body, for a unit kind whose grammar leaves its body unfielded;call_reaches_unit, which lets a language with overloading rule out a call that names the unit.
WalkCxgainsunit_node.- Existing languages score exactly as before.
0.3.0 - 2026-09-24
Section titled “0.3.0 - 2026-09-24”-
Go.
.gofiles are scanned under a newgolanguage id, so--lang go,--over go=Nand a[go]section in the config all work. A method is keyed by its receiver type, sofunc (s *Stack[T]) Push()reports asStack::Pushand its baseline entry survives switching between a pointer and a value receiver. A call through the receiver,s.Push(), counts as recursion; a barePush()inside the method names a free function or builtin and does not. Aniforswitchinitializer is scored as part of the header,selectcosts one increment like aswitch, anddefer,goandfallthroughare free. Package-level function literals are units named by their binding, and a file’s repeatedinitfunctions are told apart asinit,init~2. Built behind agocargo feature, on by default.A file with a
// Code generated … DO NOT EDIT.line before itspackageclause is skipped, neither scored nor counted, on disk and through--stdin: it is how the Go toolchain itself recognises generated code.vendor/andtestdata/are not special-cased; exclude them if a repository commits them.Upgrading a repository that contains
.gofiles will report findings that were previously invisible. Runbonsai-lint --write-baseline .to adopt them. -
Installing through Go.
go install bonsai.kauneckas.dev/bonsai-lint@latestinstalls the CLI, andgo get -toolpins it in a Go module forgo tool bonsai-lint. The module is a launcher with no dependencies, published from bonsai-lint-go at every release under the same version. The first run of each version downloads that release’s binary, checks it against the sha256 recorded in the module’s source, and caches it. Windows on ARM runs the x64 binary under emulation, as the npm package does. musl Linux and glibc older than 2.35 get a message pointing atcargo install.
Changed
Section titled “Changed”-
The macOS and Linux release archives are
.tar.gzinstead of.tar.xz, because the Go launcher unpacks them with Go’s standard library, which has no xz decoder. The installer scripts, npm package and Homebrew formula follow on their own; a script that downloads an archive by name needs the new extension. -
For embedders of
bonsai-coreandbonsai-engine, the language seam is now two traits with default methods, so a new hook no longer forces an edit into every language crate. No score moves.Hooksis a trait, reached throughLanguageSpec.hooks: &'static dyn Hooks.unit_scope, returning aUnitScope, replacesis_self_receiver: it names the receiver spellings through which a call reaches the unit, and whether a bare call does. The newif_partslets a language read its own if-chains asIfParts, defaulting towalk::if_parts_by_fields; the walker keeps the arithmetic.LanguageDescriptoris a trait, taken as&'static dyn LanguageDescriptorbyregistry::descriptors,registry::for_pathandScanner::analyze_source. Itsidfield is gone, andextract,is_generatedandunscored_suffixesare defaulted methods.- The
.d.tsand.min.jssuffix lists moved from the engine to the TypeScript and TSX descriptors.
-
--helpno longer names languages in its description, and--langlists the ids built into the binary, so a build with fewer language features no longer offers ones it cannot scan. -
In PHP and TypeScript, an IIFE whose result is bound, such as
const config = (() => { … })()or$config = (function () { … })(), now takes that binding’s name instead of<anonymous>, and a marker above the binding suppresses it, as Go does. A bare IIFE still has nothing to be named after and stays<anonymous>, numbered only among the units left anonymous, so adding a bound one no longer shifts its key. Such a unit’s baseline key changes: until--write-baselineis rerun, its old entry reports as stale and the unit as new. -
Two domains with the same name are now an error naming both, where they used to be merged under one label in silence:
--domainand the report’sdomainfield could not tell them apart. That includes a domain namedroot, which is the workspace root’s own name. Embedders see this as a newConfigError::Invalidvariant.
- A suppression marker above a declaration whose function is wrapped in a call, such as
const useCart = defineStore('cart', () => …)or$handler = wrap(function () { … }), now suppresses that function. The unit was already named after the declaration, but the marker search stopped at the call, so the marker was ignored. A marker directly above the callback, inside the argument list, keeps working, and now does in PHP too. A call that binds nothing, such asRoute::get('/x', function () { … }), still leaves a marker above it to the file. - A misspelt top-level key in
bonsai-lint.tomlis reported by name, asunknown key `treshold`; did you mean `threshold`?, instead of as “expected struct LanguageSection”. A misspelt language section,[typscript], gets the same suggestion in its warning.
0.2.1 - 2026-09-22
Section titled “0.2.1 - 2026-09-22”Changed
Section titled “Changed”- Documentation only, with no change to any score.
0.2.0 - 2026-09-21
Section titled “0.2.0 - 2026-09-21”-
Minified JavaScript is skipped, the way
*.d.tsalready was: a bundle’s nesting rolls up into one unit whose score outranks every real finding, and it is generated output nobody is going to refactor. The skipped names now live in one list of whole suffixes, which also closes a gap where*.d.mtsand*.d.ctswere scanned despite being declaration files. Names that merely resemble one, such asapp.mini.js, are untouched. A baseline holding a newly skipped file will report that entry as stale. -
Vue single-file components.
.vuefiles are scanned under a newvuelanguage id, so--lang vue,--over vue=Nand a[vue]section in the config all work. Both<script>and<script setup>are scored, withlang="ts"selecting the TypeScript grammar and anything else the JSX-capable one; each block is parsed on its own and their file-level code is added into the single<toplevel>a component reports. Reported lines are lines in the.vuefile, so editing a template moves a finding without changing its baseline key.Templates,
<style>and custom blocks such as<docs>are not scored, and asrc=block is scored as whatever file it points at. Arender()function written in a script block is ordinary code and is scored. Counting template branching would make a component incomparable with the same logic written in TypeScript. Built behind avuecargo feature, on by default and implyingts.Upgrading a repository that contains
.vuefiles will report findings that were previously invisible. Runbonsai-lint --write-baseline .to adopt them.
0.1.1 - 2026-09-20
Section titled “0.1.1 - 2026-09-20”-j,--jobs <N>bounds how many files are scored at once. Absent or0asks the machine, which respects cgroup quotas and CPU affinity, so a CI container gets the number it is actually allowed. The request is capped at four times the machine’s parallelism.
Changed
Section titled “Changed”- Files are read, parsed and scored in parallel. On a 1.26 million line monorepo and a ten-core machine, a scan drops from 3.16s to 0.80s, about 1.6 million lines per second. Scaling flattens near 4x rather than approaching 10x because six of those cores are efficiency cores, and the wall-clock win costs roughly 74% more total CPU.
- Output is unchanged. A report is byte for byte identical to the previous release’s at every thread count, stdout and stderr alike: files are scored out of order but replayed in walk order, and findings are ranked after the scan rather than printed as they arrive.
- The scorer is untouched. No change to
bonsai-core, so no score moves. - Discovery is still single-threaded, and is now most of what remains. See ROADMAP.md for the measurement and the two follow-ups it motivates.
0.1.0 - 2026-09-19
Section titled “0.1.0 - 2026-09-19”Initial release.
- Cognitive complexity scoring for PHP, JavaScript and TypeScript, read through tree-sitter without executing any of it, from one static binary with no PHP or Node runtime required.
- The same logic scores the same in every supported language, so one threshold is meaningful across a mixed repository.
- Monorepo support: domains with their own configs, thresholds, excludes and baselines.
- Baselines to accept existing findings so only later regressions fail, per domain or shared.
bonsai-lint-ignoresuppression markers, per unit or per file, with a required reason.- Text and JSON output, and
--stdinfor scoring an unsaved editor buffer. - Published as a GitHub release, an npm package, and a Homebrew formula, alongside a VS Code extension versioned independently.