monostyle
monostyle scores the complexity and readability of a codebase, a file, or a function, out of 100. Every point lost is traced to a named rule with an explanation and a suggested fix, so a report is a worklist rather than a grade.
Readability measures how the code looks: whether complex sections have room to breathe, whether sequential control flow is separated, whether deep nesting has been flattened, and whether comments explain the hard parts.
Complexity measures how hard the code is to follow and to test: cyclomatic complexity (how many independent paths exist) and cognitive complexity (how much nesting taxes the reader).
Code is read far more often than it is written, and the things that make it pleasant to read are mostly layout: a blank line before a branch, space around a long argument list, a gap between logical groups, and a comment on the one function that is genuinely hard to follow.
Those things are invisible to every existing metric. Cyclomatic complexity will happily call a flat, unreadable function simple; a formatter will happily preserve a 200-line function with no blank lines anywhere. monostyle exists to make the visual properties of code measurable, so that “this is hard to read” becomes a specific, fixable list.
Finding what to fix
$ monostyle check . --units
readability: what is costing you points
██████░░░░ 61.2% readability/blank-line-before-control-flow (4,181 findings)
crates/example/src/main.rs:91 [minor]
`if` follows the previous statement with no blank line between them
-> Add a blank line before this statement so the reader can treat it as
a separate decision rather than part of the previous block.
Each entry names its worst offender as path:line, and the report ends with the single highest-value fix:
start here
Fixing readability/blank-line-before-control-flow at crates/example/src/main.rs:91 would
recover 30.7% of the available points.
Installation
cargo install monostyle
Or build from source:
cargo build --release --package monostyle
From npm
npm install -g @monostyle-rs/cli
Usage
monostyle check [PATH]... # analyze a directory, file, or list of paths
monostyle fix [PATH]... # apply every fixable finding
monostyle fix . --dry-run # show what would change
monostyle check . --units # include per-function scores
monostyle check . --explain # list every finding with its explanation
monostyle check . --format json # machine-readable output
monostyle check . --fail-under 75 # exit non-zero below a threshold
monostyle rules # list every rule
monostyle config # print the effective configuration
Rules
Every rule has a stable identifier that appears in findings, configuration, and --disable flags. Rules are grouped by the question they answer: readability rules measure layout, complexity rules measure how hard the code is to follow, and Markdown rules measure the quality of code examples inside documentation.
Readability rules
| Rule | What it catches |
|---|---|
blank-line-before-control-flow | An if/for/while crowded against the statement above |
blank-line-before-return | A return buried against the code above it |
group-separation | A long run of statements with no blank lines between groups |
excessive-indentation | Lines indented past the readable limit |
deep-nesting | Control flow nested past the configured depth |
long-parameter-list | An argument list that should be split across lines |
overlong-line | A line wider than the readable limit |
oversized-unit | A function too long to hold in your head |
oversized-file | A file too large to navigate |
mixed-indentation | A file that indents with both tabs and spaces |
comment-required-on-complex-unit | A complex function with no explanation |
comment-explains-why | Credit for a comment that explains reasoning |
comment-narrates-code | A comment that restates what the code already says |
excessive-comments | More commentary than the code can carry |
thin-documentation | A doc block that lists structure without explaining purpose |
Complexity rules
| Rule | What it catches |
|---|---|
cyclomatic-per-unit | A function with too many independent paths to test |
cognitive-per-unit | A function that is hard to follow, reported with its nesting penalty |
npath-per-unit | A function with too many execution paths |
exits-per-unit | A function that returns from too many places |
low-maintainability | A function with a low maintainability index |
cyclomatic-per-file | A file dense with decisions |
NPath complexity
NPath counts acyclic execution paths rather than independent branches. Sequential branches multiply rather than add, so a function with fourteen sequential if statements has a cyclomatic complexity of fifteen and over sixteen thousand execution paths. The two metrics are reported separately because they answer different questions: cyclomatic complexity tells you how many tests you need, while NPath tells you how many paths a reader has to reason about.
Exit count
A function with many exits is hard to reason about because the reader must hold every escape in mind to know what it guarantees. Early returns are still preferred to nesting, so the rule only fires well past the point where guards are idiomatic.
Maintainability index
The index combines Halstead volume, cyclomatic complexity, and line count, so it catches a unit that is dense with arithmetic rather than branches — a case the other complexity rules miss.
Markdown rules
Code inside documentation is scored too, because README examples are what people copy.
| Rule | What it catches |
|---|---|
fence-readability | A cramped code example inside a fence |
fence-without-language | A fence with no language tag |
fence-language-unknown | A fence naming a language monostyle does not know |
prose-run | A wall of prose with no structure to break it up |
skipped-heading-level | A heading level that skips a step, breaking the outline |
no-title | A document that does not start with a top-level heading |
Why fences are scored differently from source
Complexity rules are deliberately excluded from fences. A documentation example often walks through a messy state on purpose, and penalizing it would push authors toward hiding the very complexity they are explaining. Only the layout rules apply, and their findings are mapped back to the Markdown file’s line numbers so a reader can jump to the exact fence.
How scoring works
Findings carry a weight; severity scales it into a penalty. Penalties sum per category and are normalized by code volume into a penalty density — findings per 100 lines — so a large well-written file is not punished for its size. Density maps to 0–100 through exponential decay:
score = 100 * 2 ^ (-density / half_life)
The half-life is the density at which a category scores exactly 50, which makes the whole curve tunable with one readable number.
Good comments earn negative penalties. That is how a well-placed explanation raises a score: the credit offsets other penalties inside the same density number, so one number always explains the result.
Scores are aggregated by line-weighted mean, the same way test coverage is aggregated. A ten-line file cannot count as much as a thousand-line file. Three invariants hold, and all three are enforced by tests: splitting a file does not change the project score, doubling penalty and volume does not change it, and fifty two-line files cannot outweigh one five-thousand-line file.
Why the half-life is tunable
The half-life is the density at which a category scores exactly 50. That makes the whole curve tunable with one readable number: raising it makes the tool more forgiving, lowering it stricter. The curve never reaches zero, which keeps scores comparable across files instead of collapsing to a floor.
Why scores are weighted by lines
A project’s score is not the average of its files’ scores. It is a line-weighted mean of their penalties, the same way coverage is aggregated, so a ten-line file cannot count as much as a thousand-line file. Three invariants hold, and all three are enforced by tests: splitting a file does not change the project score, doubling penalty and volume does not change it, and fifty two-line files cannot outweigh one five-thousand-line file.
Auto-fix
One rule is auto-fixable: inserting a blank line before a control-flow statement. That is the only edit guaranteed to survive a formatter — rustfmt, Prettier, Black, and dart format all preserve a blank line between statements and none of them remove one. A fixer that fights the project’s formatter produces a diff the next format run reverts, which is worse than the finding itself.
Every other rule explains itself and leaves the change to you. The fix output shows both: what was applied, and what still needs a decision, with the suggestion attached.
Running it
monostyle fix . # apply every fixable finding
monostyle fix . --dry-run # show what would change
monostyle fix . --rule readability/blank-line-before-control-flow
Why only one rule is auto-fixable
Inserting a blank line before a control-flow statement is the only edit guaranteed to survive a formatter. Every other rule is a judgement call — breaking a long line, renaming an identifier, extracting a function, adding an explanatory comment — and the automated version would be worse than the problem. The fix output shows what was applied and what still needs a decision, with the suggestion attached, so the reader has the same information the fixer had.
Ignoring files
Generated files are skipped by default, along with dependency caches and build output. Generated code is not written for a human, so its findings are not actionable.
Configuration
[rules.ignore]
patterns = ["**/*.spec.ts", "crates/legacy/**"]
generated = true # skip generated code (the default)
include = ["lib/hand_edited.g.dart"] # always score this one
Recognized as generated: .g.dart, .freezed.dart, .pb.rs, .pb.go, _pb2.py, .designer.cs, .gen.ts, .min.js, .bundle.js, and lock files. Ignored directories include node_modules, target, dist, build, vendor, .venv, .dart_tool, and __pycache__.
Command-line overrides
monostyle check . --include-generated # score generated code too
monostyle check . --no-ignore # read no ignore files at all
Why generated files are excluded by default
On one real repository, generated files were 784,000 of 822,000 lines and produced 615,000 findings. Those findings described the generator rather than the project, which made the score unactionable. Excluding them by default is what makes the tool useful on a repository with a large code-generation surface.
A project that ships generated code as part of its public surface can turn the exclusion off with generated = false in monostyle.toml.
Configuration
monostyle reads monostyle.toml from the analyzed directory or any parent. Only the keys you set are changed; everything else keeps its default.
[scoring]
half-life = 12.0
[rules]
# Only the fields you set are changed; everything else keeps its default.
max-nesting-depth = 3
max-parameters-inline = 3
max-cyclomatic-per-unit = 10
max-cognitive-per-unit = 15
max-line-width = 120
comment-required-above-cognitive = 10
disabled-rules = ["readability/excessive-comments"]
[rules.ignore]
patterns = ["**/*.spec.ts", "crates/legacy/**"]
generated = true
Run monostyle config to print every available key with its current value. The output is valid TOML and can be pasted into a monostyle.toml file as a starting point.
Unknown keys
An unrecognized key is an error rather than a warning. A typo in a threshold name would otherwise be silently accepted, and the project would score against settings nobody chose.
Supported languages
Rust, C, C++, C#, Java, JavaScript, Kotlin, Mozjs, Python, TypeScript, TSX, Dart, Go, Swift, Ruby, PHP, Scala, Shell, Lua, Elixir, Haskell, Nix, and Markdown.
The first eleven match what rust-code-analysis supports, so numbers from the two tools are comparable. Dart is included because it is the language this tool was built for. The remaining ten cover widely used languages that project does not reach.
Languages are described by data profiles rather than parsers, so a language is added by editing crates/monostyle_languages/src/catalog.rs rather than by writing analysis code.
Performance
Performance
Measured on an Apple Silicon laptop against four repositories, three of them real projects. Each number is the best of three runs of monostyle check --quiet, which is what a person or a CI job would actually invoke.
| Repository | Files | Lines of code | Cold | Warm |
|---|---|---|---|---|
| monostyle | 87 | 16,529 | 0.12s | 0.07s |
| mdt | 228 | 35,749 | 0.13s | 0.12s |
| monochange | 318 | 193,900 | 0.42s | 0.36s |
| pina | 2,198 | 232,417 | 1.08s | 1.12s |
A 2,198-file repository with 232,000 lines of code completes in about a second.
“Warm” means a second run with the cache populated. It is faster only where the cache can be written: monostyle and monochange have a target directory, because they have been built, and the cache lives under it. mdt and pina had no build tree in the checkout that was measured, so discovery found nowhere to cache — which is the documented behavior, and the reason those three columns show no improvement.
What made it fast
Three quadratic paths were removed. Each was invisible on a small file and dominated on a large one.
The scanner copied the file on most characters
Every step of the scan collected the entire remaining file into a String, purely to test whether a short delimiter matched:
#![allow(unused)]
fn main() {
let rest: String = characters[index..].iter().collect();
if self.profile.block_comment_at(&rest) { ... }
}
On a 7,139-line file this alone meant gigabytes of allocation, and the file did not finish within sixty seconds. Delimiter matching now reads a bounded six-character window, which answers the same question in constant work per character. That file analyzes in 2.07 seconds.
The same pattern appeared in literal_at, which ran on a large fraction of all characters: l, b, u, f, and r are ordinary identifier letters and also string prefixes, so the lookahead was triggered constantly. It was 65 seconds of the 66-second runtime. Bounding it to the same six-character window is what took the file from 65s to 0.57s.
Unit detection ran seven times per file
Seven rules ask for a file’s function-like units, and detection is a structural scan over every line. On a 17,000-line file with 499 functions that was 499 units times seven scans. Detection and the per-unit metrics now run once and are memoized, keyed by the unit’s line range so two functions with the same name in different places cannot be confused.
Halstead used linear membership tests
Distinct operators and operands were held in Vecs and tested with iter().any(), which is quadratic in the token count. They are now HashSets.
The cache
The cache stores the lexed result rather than the final report. Rules and thresholds change far more often than source does: caching the report would invalidate on every configuration change, while the line model stays valid across all of them and only the cheap rule pass repeats.
An entry is keyed by path, modification time, size, language, and a schema number. Any mismatch is a miss, which is the safe direction — a stale hit would report scores for code that no longer exists. A corrupt entry is discarded rather than reported, because a cache is an optimization and failing an analysis because one went wrong would be the wrong trade.
Measuring it yourself
cargo build --release --package monostyle
time ./target/release/monostyle check /path/to/repository --quiet
The cold and warm figures come from removing target/monostyle between runs. To see whether the cache is engaging, check that the directory exists afterward; if it does not, the checkout has no build tree for discovery to find.
Releases
Releases
A changeset describes one change and the version bump it needs. Write one with:
monochange run change --package monostyle_rules --bump patch --reason "Fix the long-parameter-list rule firing on every call"
Or write the file by hand in .changeset/:
---
"monostyle_rules": patch
---
# Fix the long-parameter-list rule firing on every call
The rule tested argument count and rendered width separately, so any call over forty columns was reported regardless of how many arguments it had. It now requires both, which is what makes the finding mean something.
The release workflow reads every changeset, computes the next version for each package, and opens a release pull request. Merging that pull request is what cuts the release, so the schedule is “whenever a release is worth shipping” rather than a fixed cadence.
What needs a changeset
A pull request that changes a published package needs one, or the release notes for that version would not mention the change. Documentation, tests, snapshots, and examples do not: none of them change what a published package does.
Trusted publishing
Releases publish with trusted publishing when a verifiable CI identity is available, and fall back to the NPM_TOKEN and CARGO_REGISTRY_TOKEN secrets otherwise. The fallback exists because a package has to exist in a registry before it can be enrolled with a trusted publisher, so the first publish of anything always uses a token. Set force_token_auth when dispatching the publish workflow to skip the OIDC exchange deliberately.
Architecture
Architecture
The decision: profile-driven lexer first, tree-sitter as a deliberate phase two
monostyle needs to read source in ~24 languages. There were two ways to do that, and the choice shapes the whole project.
Chosen: a profile-driven lexer. Each language is a data profile — comment syntax, string delimiters, interpolation style, decision keywords, block style. One scanner serves every language, and adding a language is a data edit rather than a code change.
Deferred: tree-sitter grammars, loaded as Wasm at runtime, behind a cargo feature.
Why the profile lexer, stated honestly
The tempting argument against tree-sitter is binary size, and it is wrong. Helix and Zed ship small binaries while using tree-sitter because they package grammars externally (Zed fetches Wasm grammars on demand; Helix keeps a runtime directory). For a developer CLI, a 30 MB binary would not matter either — rust-code-analysis statically links around ten grammars and ships fine.
The real costs are different:
-
Build latency and version coupling. Generated parser C is large; the C++ and TypeScript grammars take tens of seconds each to compile cold. Thirty grammars means a C toolchain in the build graph, minutes of cold compilation, and thirty separate parser-to-runtime ABI relationships to keep aligned. In a devenv setup aiming for reproducibility across macOS and Linux, that tax is paid on every fresh environment.
-
Tree-sitter only replaces half the work. Our metric is layout-first. Whether there is a blank line before an
if, whether logical groups are separated, how dense the comments are, how deep the indentation runs — these are line- and token-level properties. Tree-sitter hands back byte ranges; detecting blank lines still requires a separate pass over the source with its own trivia handling. Paying for thirty C toolchains to still write the layout pass is a poor trade. -
Two coverage gaps point the same way. Dart — the language this project exists for — is absent from
rust-code-analysisand has a less mature grammar than the flagship ones. Markdown code fences need per-fence grammar injection under either design.
What this costs us, and how we pay for it
The honest risk is parsing correctness in exactly one place: comments and string literals. A hand-written scanner gets literal syntax wrong, and those errors corrupt the scores we exist to produce. The concrete failure modes, all of which have dedicated regression tests:
| Case | Why naive scanning breaks |
|---|---|
Dart '''/""" and $/${} interpolation | The closing delimiter is three characters, and interpolation can nest quotes |
Rust r#"…"# with arbitrary hash counts | The close delimiter depends on an open-time count |
| Rust nested block comments | /* /* */ */ closes after the second */, not the first |
| JS regex versus division | / is a regex only in operand position; a misread swallows the line |
JS template literals with nested ${…} | Interpolation can contain braces, strings, and further templates |
| Python f-strings | {…} nests (and the rules changed in 3.12); {{ is a literal brace |
| Shell heredocs | The body is data until a line equal to the delimiter; <<'EOF' disables interpolation |
Nix ''…'' indented strings | ''$ and ''${ are escapes, so '' alone does not always close |
So the correctness effort is concentrated deliberately: the scanner is a pushdown state machine with an explicit context stack, and crates/monostyle_lexer/tests/trivia.rs is the heaviest test suite in the repository.
Why the decision is reversible
The scanner’s contract is a line-oriented model: indentation, blankness, comment intent, decision points, parameter spans. Tree-sitter can be added later behind a feature flag without touching the rules, because the rules consume that model rather than tokens. The honest framing of the phase-two upgrade is not “tree-sitter is heavy” but: cognitive complexity’s nesting penalty is the one metric that genuinely benefits from real structure, and the right way to add it is Wasm-loaded grammars, not thirty statically linked C parsers. That upgrade should land once the scoring rules have settled.
Crate layout
monostyle_core domain types: Span, Finding, Category, Severity, Score, Language
monostyle_languages language profiles as data
monostyle_lexer the scanner and its line model
monostyle_metrics cyclomatic + cognitive complexity, unit detection
monostyle_rules the rule set
monostyle_markdown Markdown fence extraction and prose structure
monostyle the CLI
Dependencies flow one direction: core is depended on by everything and depends on nothing; rules sits at the top of the library stack. This is enforced by cargo deny and by the workspace having no cycles.
Scoring model
Findings carry a weight; Severity scales it into a penalty. Penalties sum per category and are normalized by code volume into a penalty density (per 100 lines). Density maps to 0–100 through exponential decay with a configurable half-life:
score = 100 * 2 ^ (-density / half_life)
The half-life is the density at which a category scores exactly 50, which makes the whole curve tunable with one readable number. Density rather than raw count means a large, well-written file is not punished for its size.
Findings may carry a negative weight. That is how a well-placed why-comment earns credit: it offsets other penalties inside the same density number, so one number always explains the final score.