Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

RuleWhat it catches
blank-line-before-control-flowAn if/for/while crowded against the statement above
blank-line-before-returnA return buried against the code above it
group-separationA long run of statements with no blank lines between groups
excessive-indentationLines indented past the readable limit
deep-nestingControl flow nested past the configured depth
long-parameter-listAn argument list that should be split across lines
overlong-lineA line wider than the readable limit
oversized-unitA function too long to hold in your head
oversized-fileA file too large to navigate
mixed-indentationA file that indents with both tabs and spaces
comment-required-on-complex-unitA complex function with no explanation
comment-explains-whyCredit for a comment that explains reasoning
comment-narrates-codeA comment that restates what the code already says
excessive-commentsMore commentary than the code can carry
thin-documentationA doc block that lists structure without explaining purpose

Complexity rules

RuleWhat it catches
cyclomatic-per-unitA function with too many independent paths to test
cognitive-per-unitA function that is hard to follow, reported with its nesting penalty
npath-per-unitA function with too many execution paths
exits-per-unitA function that returns from too many places
low-maintainabilityA function with a low maintainability index
cyclomatic-per-fileA 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.

RuleWhat it catches
fence-readabilityA cramped code example inside a fence
fence-without-languageA fence with no language tag
fence-language-unknownA fence naming a language monostyle does not know
prose-runA wall of prose with no structure to break it up
skipped-heading-levelA heading level that skips a step, breaking the outline
no-titleA 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.

RepositoryFilesLines of codeColdWarm
monostyle8716,5290.12s0.07s
mdt22835,7490.13s0.12s
monochange318193,9000.42s0.36s
pina2,198232,4171.08s1.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:

  1. 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.

  2. 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.

  3. Two coverage gaps point the same way. Dart — the language this project exists for — is absent from rust-code-analysis and 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:

CaseWhy naive scanning breaks
Dart '''/""" and $/${} interpolationThe closing delimiter is three characters, and interpolation can nest quotes
Rust r#"…"# with arbitrary hash countsThe 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 heredocsThe 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.