Kaizo

『スーパーマリオワールド カイゾウ』

A strict, punishing RuboCop extension aimed at AI-agent-authored Ruby — holding generated code to a demanding design bar.

Kaizo (改造) — "remodeling", "modification": the ruleset keeps applying pressure until the code is remade into something better. A long argument list is the canonical target — loose primitives that want to be modeled as an object:

# bad — four loose primitives thread through the signature
def calculate_volume(width, length, height, shape_type)
end

# good — the abstraction has a name
def calculate_volume(shape)
end
$ rubocop --only Kaizo volume.rb
volume.rb:1:5: C: Kaizo/PositionalArguments: Method has too many positional arguments. [4/1]
def calculate_volume(width, length, height, shape_type)
    ^^^^^^^^^^^^^^^^
volume.rb:1:5: C: Kaizo/TotalArguments: Method has too many arguments. [4/2]
def calculate_volume(width, length, height, shape_type)
    ^^^^^^^^^^^^^^^^

1 file inspected, 2 offenses detected

Every example in this README is executed against the shipped configuration by the test suite: # bad code really is flagged by the named cop, # good code really passes every cop, every YAML snippet is valid config, and the terminal output above is re-derived from the cops themselves. Where a section covers several cops, each bad example names the one that fires.

Cops

Cop Flags
Kaizo/PositionalArguments more than Max positional parameters
Kaizo/KeywordArguments more than Max keyword parameters
Kaizo/TotalArguments more than Max parameters in total
Kaizo/AgentNounClassName classes named for what they do, not what they model
Kaizo/NestedMethodCalls calls buried in other calls' arguments
Kaizo/SpecComment comments in spec files
Kaizo/SpecDescriptionProse descriptions that are lists, conditions, or code
Kaizo/SpecSubject the unit under test hidden in a let
Kaizo/FileUtilsInclusion repeated FileUtils. qualification
Kaizo/PreferPathname File class methods Pathname already provides
Kaizo/TempfileCreate temp-file APIs with nondeterministic cleanup
Kaizo/ExplicitBegin rescue/ensure attached straight to def
Kaizo/NextInNonVoidEnumerable next as a value in map/select/reduce
Kaizo/PluralCollectionName arrays returned under singular names

Every cop ships enabled. plugins: [kaizo] turns all fourteen on at their strict defaults — there is nothing to opt into and no pending status. Outside its own department kaizo touches two core cops: it disables Style/RedundantBegin (Explicit begin) and sets Style/HashSyntax to enforce Ruby 3.1's hash-value shorthand — Session.new(table:) over Session.new(table: table).

The three argument cops are independent dimensions — enable the smallest set that expresses your rule; a method breaking several bounds is reported once per cop. None of the cops autocorrect — every fix is a design decision — with one exception: Kaizo/ExplicitBegin, whose begin/end wrap is mechanical (rubocop -a).

Installation

gem 'kaizo', require: false
plugins:
  - kaizo

Requires RuboCop 1.72.2+ for the lint_roller plugin API.

Argument counts

The defaults are deliberately strict — at most one positional and one keyword argument. Loosen them, or set a Max to 0 to forbid that kind entirely:

Kaizo/PositionalArguments:
  Max: 1        # default; 0 forces every argument to be a keyword
Kaizo/KeywordArguments:
  Max: 1        # default
Kaizo/TotalArguments:
  Max: 2        # default (one positional + one keyword)

All three check def, def self., define_method, and define_singleton_method. Required and optional parameters count alike:

# bad — Kaizo/TotalArguments: two positional plus two keyword exceed Max 2
def route(verb, path, to:, name: nil)
end

# bad — Kaizo/KeywordArguments: three keyword parameters exceed Max 1
def connect(host:, port:, scheme:)
end

# good — Kaizo/KeywordArguments counts none of these: collectors are single
# tokens, not lists of primitives
def log(*messages, **context, &formatter)
end

The keyword-counting cops skip spec/ and test/ trees entirely: wide keyword interfaces are the testing idiom — FactoryBot's create/build, custom builder helpers — and keywords communicate fine at any width there. Positional pressure is universal, because positional arguments communicate nothing unless they are solo:

# good — spec/support/builders.rb: a wide keyword builder is normal test
# infrastructure, so Kaizo/KeywordArguments and Kaizo/TotalArguments skip it
def create_order(customer:, items:, coupon: nil, shipping: :standard)
  Order.create(customer:, items:, coupon:, shipping:)
end

# bad — Kaizo/PositionalArguments: spec/support/builders.rb is not exempt
# from positional pressure; three anonymous values say nothing
def build_order(customer, items, coupon)
  Order.create(customer:, items:, coupon:)
end

Police tests like everything else by clearing the exclusion:

Kaizo/KeywordArguments:
  Exclude: []        # count keywords in tests too
Kaizo/TotalArguments:
  Exclude: []

Two shapes are structurally exempt:

# good — a Struct/Data initialize mirrors the value object's attributes,
# which is exactly the modeling these cops push toward
Data.define(:width, :height, :depth) do
  def initialize(width:, height:, depth:)
    super
  end
end

# good — operator arity is fixed by Ruby's syntax; there is no object to
# extract (`[]`, `<=>`, `+`, `<<`, and the rest of the family likewise)
def []=(row, column, value)
  @cells[row][column] = value
end

Beyond the structural exemptions, exempt methods by name or pattern:

Kaizo/KeywordArguments:
  Max: 1
  AllowedMethods:
    - initialize     # constructors may gather collaborators
Kaizo/PositionalArguments:
  AllowedPatterns:
    - '\Abuild_'     # or exempt a whole naming family

define_method edge cases

# good — only the block form of define_method is inspected; a callable body
# may be any object that responds to call, and is not statically countable
define_method(:resize, ->(width, height) { @size = [width, height] })

# good — numbered and `it` parameters are not a declared signature, so they
# count as zero; spell parameters out if you want them counted
define_method(:squared) { _1 * _1 }

# bad — Kaizo/TotalArguments: a name computed at runtime is still checked;
# the declared parameters matter, not how the name is spelled
define_method(:"handle_#{event}") { |source, payload, context| dispatch(source) }

Relationship to Metrics/ParameterLists

Core's Metrics/ParameterLists caps the whole parameter list; kaizo bounds positional and keyword arguments separately and is framed around domain modeling. They can coexist. Full rationale with reproducible evidence: docs/why-not-metrics-parameterlists.md.

Class naming

Kaizo/AgentNounClassName flags classes named as agent nouns — "doers" — rather than the domain concepts they model: names ending in er/or, or in a configured forbidden suffix like Service.

# bad — an -er name describes behavior, not a concept
class PaymentProcessor
end

# bad — Struct/Data/Class constant assignments are checked too
RequestHandler = Data.define(:request)

# good
class Payment
end

# good — ends in an allowed suffix
class UsersController
end

Two configurable suffix lists drive it, matched against the last segment of a namespaced name. AllowedSuffixes exempts legitimate -er/-or words and ships with a broad default (Adapter, Controller, Error, User, ...); ForbiddenSuffixes always wins, which is how a default exemption is dropped:

Kaizo/AgentNounClassName:
  inherit_mode:
    merge:
      - AllowedSuffixes
      - ForbiddenSuffixes
  AllowedSuffixes:
    - Voucher     # PaymentVoucher now passes
  ForbiddenSuffixes:
    - Server      # ApiServer now flagged, despite the default allowance

Nested method calls

Kaizo/NestedMethodCalls flags calls nested too deeply in argument positions — intermediate results want names.

# bad
wrap(parse(read(io)))

# good — the name turns the step into its own documentation
parsed_config = parse(read(io))
wrap(parsed_config)

# good — a single nested call is fine at the default Max
puts compute(value)

A local called result or tmp satisfies the cop but not the reader — the point is the name. Only argument nesting counts: receiver chains (user.account.owner) are a separate concern, operator methods never count, and block bodies are not traversed.

Kaizo/NestedMethodCalls:
  Max: 1            # default; raise to allow deeper nesting
  AllowedMethods:
    - expect        # e.g. don't count RSpec's expect(...) wrapper

Comments in specs

Kaizo/SpecComment flags comments in spec files: a sentence explaining an example usually wants to be a context/it description, a clearer example name, or another example.

# bad
it 'permits the request' do
  # an admin can see everything
  user = create(:user, admin: true)
  expect(policy).to permit(user)
end

# good
it 'permits an admin to see everything' do
  admin = create(:user, admin: true)
  expect(policy).to permit(admin)
end

Only *_spec.rb files are inspected; spec/helpers/ and spec/support/ hold infrastructure, not specs, and are excluded by default. Magic comments, # rubocop: directives, and shebangs are never flagged.

Kaizo/SpecComment:
  inherit_mode:
    merge:
      - Include
  Include:
    - '**/spec/**/*'     # spec_helper, support/, factories
    - '**/*_test.rb'     # Minitest / Test::Unit
  Exclude: []            # police spec/helpers and spec/support too
  AllowedPatterns:
    - '\A#\s*@rbs'       # permit rbs-inline type annotations

Spec description prose

Kaizo/SpecDescriptionProse requires it/context descriptions to read as one-behavior prose. Every rule is structural — it fires only when the wording signals that one example is really several, or that the assertion is leaking into the name.

# bad
it "renders the name, image, and flag"     # a comma joins several behaviors
it "omits the key when the role is unset"  # a condition belongs in a context
it "renders the :cpu member"               # code is not prose
context "the role is unset" do             # contexts open with when/with/without/after
end

# good
it "renders the name"
it "renders the cpu member"
it "raises Timeout::Error"
context "when the role is unset" do
  it "omits the key"
end

describe strings name the unit under test and are exempt. So is an error class name (raises Timeout::Error above) — the error is what the user ultimately sees, so it is the specified behavior. The ForbiddenWords defaults are curated, not exhaustive: for is a preposition in most descriptions, and homographs like given collide with prose, so they are left out — add them back if you want them:

Kaizo/SpecDescriptionProse:
  inherit_mode:
    merge:
      - ForbiddenWords
      - AllowedPatterns
  ForbiddenWords:
    - given              # flag `given ...` descriptions too
  AllowedPatterns:
    - 'Foo::Widget'      # this one identifier is allowed anywhere

Before 0.9 these lists were named Conjunctions and ContextPrefixes; the old keys are no longer read.

Spec subject

Kaizo/SpecSubject requires the unit under test to be declared with subject, not hidden in a letsubject is RSpec's name for the object being specified, and declaring it unlocks is_expected one-liners.

# bad
RSpec.describe Session::Pool do
  let(:pool) { described_class.new }
end

# good
RSpec.describe Session::Pool do
  subject(:pool) { described_class.new }
end

A let is flagged only when its block confidently builds the class under test: a .new of described_class, of the constant an enclosing describe/context names (full or short name), or of a constant matching the spec's filename (pool_spec.rb names Pool). Deliberate second instances are the escape hatch's job:

Kaizo/SpecSubject:
  AllowedMethods:
    - other          # subject == other comparisons
  AllowedPatterns:
    - '\Aother_'

Including FileUtils

Kaizo/FileUtilsInclusion flags a class or module (reported once) that qualifies FileUtils. more than once: include it for instance-level use, extend it for class-level use, and call the methods unqualified.

# bad
class Backup
  def run
    FileUtils.mkdir_p(dir)
    FileUtils.cp(src, dir)
  end
end

# good
class Backup
  include FileUtils

  def run
    mkdir_p(dir)
    cp(src, dir)
  end
end

A single qualified call is left alone, a namespace already mixing in FileUtils is not flagged, and nested classes are counted on their own.

Prefer Pathname

Kaizo/PreferPathname flags File class methods with a Pathname instance-method equivalent — once a path is a Pathname, calling the method on it reads better than threading a string through File.

# bad
File.read(path)
File.exist?(path)
File.join(dir, name)

# good
path.read
path.exist?
dir.join(name)

The banned set is the intersection of File's class methods and Pathname's public instance methods, so File.new is left alone. A few equivalents are not drop-in — Pathname#join treats an absolute segment as a reset, Pathname#chmod acts on one receiver where File.chmod is variadic — which is part of why there is no autocorrection. Executables often work with raw path strings, so exe/**/* and bin/**/* are skipped by default:

Kaizo/PreferPathname:
  Exclude:
    - 'exe/**/*'
    - 'bin/**/*'
    - 'db/**/*'      # add your own

Temp files

Kaizo/TempfileCreate requires block-form Tempfile.create — the only temp-file API whose cleanup is deterministic.

# bad
file = Tempfile.new("report")     # removed in a GC finalizer, or never
file = Tempfile.open("report")    # the same finalizer gamble
file = Tempfile.create("report")  # a bare File that is never auto-removed

# good — closed and removed when the block returns, however it returns
Tempfile.create("report") do |file|
  file.write(data)
end

Explicit begin

Kaizo/ExplicitBegin requires an explicit begin/end block when a method body attaches rescue/ensure directly to the def: the begin marks exactly what is guarded, so the method can grow without silently widening what the rescue covers.

# bad
def foo
  do_something
ensure
  cleanup
end

# good
def foo
  begin
    do_something
  ensure
    cleanup
  end
end

Modifier rescues (foo rescue nil) and endless definitions are not flagged. This is the one cop that autocorrects (rubocop -a); the correction skips single-line definitions and bodies holding heredocs or other multiline literals, where re-indenting could change their contents.

Because core's Style/RedundantBegin enforces the exact opposite style, loading this plugin disables it — otherwise the two autocorrections would loop forever. To opt out of explicit begins, disable this cop — re-enabling Style/RedundantBegin alone would leave both cops on, each flagging the form the other mandates:

Kaizo/ExplicitBegin:
  Enabled: false    # opt out of explicit begins
Style/RedundantBegin:
  Enabled: true     # optional: enforce the inverse style instead

Next in value-returning blocks

Kaizo/NextInNonVoidEnumerable flags next inside the block of a value-returning Enumerable method — map, select, filter_map, reduce, sum, the *_by methods, the predicates — where next is control flow being used as a value.

# bad
array.map do |item|
  next if skip?(item)
  transform(item)
end

# bad — `next <value>` counts too
array.reduce(0) do |sum, item|
  next sum if skip?(item)
  sum + item
end

# good — a void iteration method; `next` just skips the iteration
array.each do |item|
  next if skip?(item)
  process(item)
end

# good — say what you mean
array.filter_map { |item| transform(item) unless skip?(item) }

Void iteration methods (each, each_with_object, ...) and non-Enumerable loops (loop, while, Integer#times) are never flagged, and a next bound to a nested block or loop is attributed to that inner scope.

Kaizo/NextInNonVoidEnumerable:
  AllowedMethods:
    - reduce        # permit `next <acc>` guards in reduce/inject

Plural names for collections

Kaizo/PluralCollectionName flags a method that hands back an array under a singular name — users tells the caller what they are getting; user actively misleads them.

# bad
def user
  [first_match, second_match]
end

# good
def users
  [first_match, second_match]
end

# good — not confidently a collection (one branch is not an array), so the
# cop deliberately errs toward silence
def user
  return nil if missing?

  [first_match, second_match]
end

A method is flagged only when every value it can return is unambiguously an array: a literal, or a call to an ArrayMethods entry. select/reject are absent from that default on purpose — on a Hash they return a Hash. A name counts as plural when it ends in s or appears in IrregularPlurals; predicates, writers, operators, and initialize are exempt.

Kaizo/PluralCollectionName:
  inherit_mode:
    merge:
      - ArrayMethods
      - IrregularPlurals
  ArrayMethods:
    - fetch_all     # your own collection-returning helper
  IrregularPlurals:
    - alumni        # plural without a trailing `s`

Development

bin/setup                 # install dependencies
bundle exec rake          # run specs + self-lint
bundle exec rake spec     # specs only

License

Available as open source under the terms of the MIT License.