rubocop-appdev

RuboCop configuration and custom cops for firstdraft classroom codebases. Keeps starter and solution code written in the verbose, explicit style taught in our courses — hash rockets, explicit parens, string param keys, where(...).first over find, and so on — so students never encounter polished Rails idioms they haven't been taught yet.

Built on top of StandardRB and the standard-performance / standard-rails extensions.

Installation

# Gemfile
group :development, :test do
  gem "rubocop-appdev"
end

Then pick one of the three configurations in your project's .rubocop.yml:

# For starter/solution codebases authored by instructors
inherit_gem:
  rubocop-appdev: config/author.yml
# For student project templates
inherit_gem:
  rubocop-appdev: config/student.yml

The three configurations

Config Intended audience Hash syntax Parens on method calls Custom Appdev cops
config/shared.yml Don't inherit directly — used by the two below. Defined (but governed by the specific config).
config/student.yml Student homework. Permissive. Any style allowed. Any style allowed. Disabled.
config/author.yml Starter / solution codebases authored by instructors. Strict. Hash rockets required ({ :a => 1 }). Required on every method call, including Rails macros (belongs_to(:user, ...)). All enabled.

Both student and author configs share:

  • Linting scoped to app/**/*.rb and config/routes.rb.
  • StandardRB as the base (so Lint cops like UselessAssignment, Void, DuplicateHashKey are on).
  • The "make it more idiomatic" cluster is disabled (GuardClause, IfUnlessModifier, NumericPredicate, SafeNavigation, SymbolProc, RedundantReturn, RedundantSelf, ConditionalAssignment, RedundantBegin, Next). These would push code toward syntax students haven't learned.
  • Force-verbose overrides: no %i[...], no %w[...], no def foo; end one-liners.
  • Layout/LineLength disabled — authors write descriptive messages.

Custom cops (author config only)

All namespaced under Appdev/ and enabled by default in author.yml.

Appdev/PreferWhereOverFind

Forbids Model.find, Model.find_by, Model.find_by!. Autocorrects to .where(...).first! / .first — behavior-preserving across the rewrite since find and .first! both raise ActiveRecord::RecordNotFound.

# bad
Movie.find(id)
Movie.find_by(:title => "x")
Movie.find_by!(:title => "x")

# good
Movie.where({ :id => id }).first!
Movie.where({ :title => "x" }).first
Movie.where({ :title => "x" }).first!

Appdev/NoResourcefulRoutes

Forbids resources and resource in config/routes.rb. Students write explicit verb-mapped routes. root, namespace, scope, mount, match, and the HTTP verb methods (get, post, put, patch, delete) are all allowed.

# bad
resources :movies

# good
get("/movies", { :controller => "movies", :action => "index" })
post("/insert_movie", { :controller => "movies", :action => "create" })

Appdev/NoMassAssignment

Forbids mass-assignment ActiveRecord APIs so authors demonstrate attribute-by-attribute assignment. Scoped to app/controllers/**/*.rb and app/models/**/*.rb.

Uses a receiver-shape heuristic to dodge false positives: class-method rules (.new / .create / .create!) only fire when the receiver is a constant not in the NonARConstants allowlist; instance-method rules (.update / .update! / .assign_attributes / .attributes=) fire on instance variables and locals whose names don't hint at a hash (hash, map, dict, params).

# bad
Movie.new(:title => "x")
@movie.update({ :title => "x" })
@movie.assign_attributes(h)

# good
the_movie = Movie.new
the_movie.title = params.fetch("query_title")
the_movie.save

Appdev/StringParamKeys

Requires params.fetch("string_key"). Flags three variants and autocorrects to the blessed form:

# bad
params[:path_id]          # -> params.fetch("path_id")
params["path_id"]         # -> params.fetch("path_id")
params.fetch(:path_id)    # -> params.fetch("path_id")

# good
params.fetch("path_id")

Reinforces two pedagogical points at once: string keys (so students see the underlying hash) and .fetch (so missing keys raise instead of silently returning nil).

Appdev/ExplicitRenderAndRedirect

Requires the hash form for render, a string literal for redirect_to, and a string fallback_location for redirect_back.

# bad
render :new
render "movies/index"
redirect_to @movie
redirect_to places_url
redirect_back(:fallback_location => request.referer)

# good
render({ :template => "movies/index" })
render({ :partial => "form" })
redirect_to("/movies")
redirect_to("/movies/#{movie.id}", { :notice => "Created." })
redirect_back(:fallback_location => "/movies")

Appdev/ExplicitAssociationOptions

Requires explicit options on ActiveRecord associations so students see the column and class being wired up, rather than relying on Rails' pluralization magic.

  • belongs_to, has_one, has_many (without :through) — must pass :class_name and :foreign_key.
  • has_many :through and has_one :through — must pass :class_name, :source, and :through.
  • has_and_belongs_to_many — banned outright; use has_many :through instead.
# bad
belongs_to :user
has_many :reviews
has_and_belongs_to_many :tags

# good
belongs_to(:user, { :class_name => "User", :foreign_key => "user_id" })
has_many(:reviews, { :class_name => "Review", :foreign_key => "place_id" })
has_many(:reviewers, { :through => :reviews, :source => :reviewer, :class_name => "User" })

Development

bundle install
bundle exec rspec

Specs use RuboCop's expect_offense helper — one file per cop under spec/rubocop/cop/appdev/.

License

MIT. See LICENSE.txt.