Repository navigation
Releases: tools4imps/exhale-ruby
Release list
exhale 0.2.0: exhale complexity
exhale complexity is exhale's second check. It fails a pull request that leaves a Ruby method harder to follow than it found it.
Agents add a branch here and a nested condition there, and every change looks reasonable in its own diff. A month later the method is a maze nobody chose to build. The gate stops that one pull request at a time and tells the agent which lines to pull out.
$ bin/exhale complexity --base origin/main
exhale complexity: 1 units, 1 raised, failing base 862d720 floor 8
RAISED Invoice#total 0 -> 9 (floor 8)
app/models/invoice.rb:2-14
line 5 +4 if, &&
line 4 +2 if
line 3 +1 sum block
line 7 +1 else
line 10 +1 else
hint bring it back to 8 or less: pull the worst lines into named methods
It judges the change
Every Ruby method and Rails DSL body (scope, callbacks, before_action blocks) is scored twice, at the head and at the merge base. A method fails when its score rose and it ends above the floor, which is 8 by default. A new method fails when it starts above the floor.
Code that was already complex passes until someone makes it worse, so you can turn the gate on in a legacy app today without a cleanup sprint first. Splitting a method into smaller ones passes even though the total goes up, because the total never counts. Renamed and moved methods keep their history. exhale matches them by identity first, then by the shape of their code, using the same fingerprints exhale dry uses.
The score
The score is G. Ann Campbell's Cognitive Complexity, read off the Prism tree. A method that reads straight down scores low however long it is. Each if, unless, ternary, loop, case, rescue and iterating block costs 1 plus how deeply it's nested. A branch inside a loop inside a block costs 3.
The Ruby rules are written down in the Contract, so a score never depends on taste:
&.,||=memoization and guard returns cost nothing beyond their condition.- A whole
caseorcase/incosts 1, however many branches it has. - A run of
&&costs 1. Switching to||costs another. - Blocks on
each,map,select,each_with_objectand the rest count as loops. Other blocks only deepen the nesting. send,define_method,instance_evaland friends cost 1 each, reported apart as metaprogramming.
Why not RuboCop's complexity cops? They report only methods over a fixed limit, so a method under it has no score to compare. They also have no weight for nesting, and they charge a point for @x ||= .... Studies of these metrics agree that absolute numbers mostly track method size. Which way a method's score moved holds up better as a signal, so that's what the gate judges.
Keeping complexity on purpose
A parser's dispatch or a state machine can be complex for good reasons. Keep it in the Contract, next to the reason:
<!-- contract/lexer/complexity.md -->
## The tokenizer dispatches on every token type
```ceiling
max: 30
Lexer::Tokenizer#next_token
```A kept method fails only when it rises past its max. A ceiling that becomes unnecessary is stale, and stale clauses fail the gate, so exceptions don't outlive their reasons. A settings block can set a primitive's own floor.
Also in 0.2.0
exhale complexity explain Billing::Rates#lookupprints every point, its line and the construct that earned it.--format jsonlists every unit with its score, metaprogramming points and label.--format ednwrites the entries Uncle Bob's crapper writes, so uml-viewer can read them.- The Contract loads per check.
exhale dryreadsduplication.md,exhale complexityreadscomplexity.md, and a block in the wrong file is an error. - The mutation gate moves to Mutineer 1.5, and
bin/mutate --matrixreports blind and redundant tests.
How it was built
The Contract came first: 15 new obligations across the cognitive and ratchet primitives. An independent review found two bugs, and both got tests before their fixes. exhale now has 82 obligations, every one with an executable test. The mutation gate killed all 340 mutants on the new code, with 6 listed as equivalent with reasons. CI runs both checks on exhale itself.
Upgrade
gem "exhale", "~> 0.2", require: false- run: bin/exhale complexity --base origin/${{ github.base_ref || 'main' }}exhale 0.1.0
First release. exhale dry sweeps the whole codebase for duplicated Ruby and HTML ERB and fails while any copy isn't kept by the Contract.
- Units from Prism (methods, Rails DSL bodies, concerns) and Herb (HTML ERB templates), normalized so the names of operations survive and the names of things become markers.
- Rarity-weighted Jaccard over subtree fingerprints, with exact prefix filtering for whole units, exact subtree digests for copied fragments, and statement-run seeds for code lifted out of the middle of a method.
- The verdict depends only on the commit. The merge base labels each finding introduced, shifted, already there, kept or contracted, and
--introduced-onlyis the on-ramp for codebases that don't sweep clean yet. - The Contract under
contract/<primitive>/keeps deliberate duplication (parallel), maps primitives to code (covers) and holds settings (settings). Stale clauses and unknown references fail the gate. - Text, JSON and EDN reports. EDN uses the shape Uncle Bob's dryer writes.
- exhale ships with its own Contract (64 obligations, all executable), a clean run on itself, and a mutation gate where every Mutineer mutant is killed or listed with a reason.