Skip to content

Releases: tools4imps/exhale-ruby

exhale 0.2.0: exhale complexity

Choose a tag to compare

@obie obie released this 07 Oct 15:20
6c00100

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 case or case/in costs 1, however many branches it has.
  • A run of && costs 1. Switching to || costs another.
  • Blocks on each, map, select, each_with_object and the rest count as loops. Other blocks only deepen the nesting.
  • send, define_method, instance_eval and 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#lookup prints every point, its line and the construct that earned it.
  • --format json lists every unit with its score, metaprogramming points and label. --format edn writes the entries Uncle Bob's crapper writes, so uml-viewer can read them.
  • The Contract loads per check. exhale dry reads duplication.md, exhale complexity reads complexity.md, and a block in the wrong file is an error.
  • The mutation gate moves to Mutineer 1.5, and bin/mutate --matrix reports 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' }}

https://rubygems.org/gems/exhale/versions/0.2.0

exhale 0.1.0

Choose a tag to compare

@obie obie released this 03 Oct 01:21
8b4683b

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-only is 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.

https://rubygems.org/gems/exhale/versions/0.1.0