<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://lovro-bikic.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://lovro-bikic.github.io/" rel="alternate" type="text/html" /><updated>2026-08-06T22:56:03+00:00</updated><id>https://lovro-bikic.github.io/feed.xml</id><title type="html">Lovro Bikić</title><subtitle>Ruby Stuff</subtitle><entry><title type="html">RuboCop Lazy Loads Cops Now</title><link href="https://lovro-bikic.github.io/rubocop-lazy-loaded-cops/" rel="alternate" type="text/html" title="RuboCop Lazy Loads Cops Now" /><published>2026-08-04T00:00:00+00:00</published><updated>2026-08-04T00:00:00+00:00</updated><id>https://lovro-bikic.github.io/rubocop-lazy-loaded-cops</id><content type="html" xml:base="https://lovro-bikic.github.io/rubocop-lazy-loaded-cops/"><![CDATA[<p>A mildly exciting performance refactor has landed in RuboCop <a href="https://github.com/rubocop/rubocop/releases/tag/v1.89.0">v1.89.0</a>: <a href="https://github.com/rubocop/rubocop/issues/14983">lazy-loaded cops</a>! In short:</p>

<ul>
  <li>RuboCop used to require all 600+ cops at startup,</li>
  <li>it now autoloads cops instead of requiring them,</li>
  <li>startup time has improved noticeably (10% up to 25%, depending on use case),</li>
  <li>plugin maintainers should migrate to the new API for everyone to benefit.</li>
</ul>

<p>The rest of the post is this summary expanded with details.</p>

<h2 id="requires-just-kept-growing">Requires Just Kept Growing</h2>

<p>Some context first. Early versions of RuboCop didn’t have many cops. For example, v0.3.0 (released in 2013) had only 31; departments didn’t even exist as a concept. Back then, it was fine to <a href="https://github.com/rubocop/rubocop/blob/c28cfea0264059e02d9865e9a0f670737f9b5fa1/lib/rubocop.rb">require all cops in <code class="language-plaintext highlighter-rouge">lib/rubocop.rb</code></a>.</p>

<p>But RuboCop kept growing. By v1.0.0, the number of cops exceeded 400. They were still required the same way; as a result, <code class="language-plaintext highlighter-rouge">lib/rubocop.rb</code> was <a href="https://github.com/rubocop/rubocop/blob/v1.0.0/lib/rubocop.rb">pretty long</a>.</p>

<p>Today, there are <a href="https://github.com/rubocop/rubocop/blob/v1.88.2/lib/rubocop.rb">more than 600 cops</a>. And with plugins included (e.g. <code class="language-plaintext highlighter-rouge">rubocop-rails</code>, <code class="language-plaintext highlighter-rouge">rubocop-rspec</code>, <code class="language-plaintext highlighter-rouge">rubocop-performance</code>, etc.), that number is even higher.</p>

<p>Requiring so many cops wouldn’t be an issue were we to actually use them all in each RuboCop run. But we don’t:</p>

<ul>
  <li>most RuboCop configs enable only a subset of cops (e.g. <a href="https://github.com/standardrb/standard/blob/v1.56.0/config/base.yml">Standard currently enables ~60%</a> of all cops),</li>
  <li>you can run RuboCop with a single department (e.g. <code class="language-plaintext highlighter-rouge">bundle exec rubocop -x</code> runs only Layout cops),</li>
  <li>or even a single cop (e.g. <code class="language-plaintext highlighter-rouge">bundle exec rubocop --only Style/HashSlice</code>).</li>
</ul>

<p>Now a <code class="language-plaintext highlighter-rouge">require</code> in itself is not necessarily a slow operation, but hundreds of requires do add up:</p>

<p><img src="/images/rubocop_before.png" width="100%" /></p>

<p>This is a performance profile (created with <a href="https://github.com/jhawthorn/vernier">Vernier</a>) of a RuboCop run with a single cop on just one file. The many “icicles” in the red box are all the cops being required. Even if you can’t understand this chart very well, it’s quite obvious that the highlighted part sticks out.</p>

<p>So, at this scale, requiring all cops at startup is wasteful. To optimize execution times, a natural solution is to load a cop file only when the cop is going to be used, i.e. load it lazily.</p>

<h2 id="some-technical-details">Some Technical Details</h2>

<p>Ruby already has an API to lazy load a class/module: <a href="https://docs.ruby-lang.org/en/4.0/Kernel.html#method-i-autoload">Kernel#autoload</a>.</p>

<p>You might think that to lazy load cops, you just have to replace <code class="language-plaintext highlighter-rouge">require</code> with <code class="language-plaintext highlighter-rouge">autoload</code>. If it were that easy, this would be the shortest section in the post.</p>

<p>Here’s the thing. All cops inherit from the <code class="language-plaintext highlighter-rouge">RuboCop::Cop::Base</code> class, which defines a class method <code class="language-plaintext highlighter-rouge">inherited</code>:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># lib/rubocop/cop/base.rb</span>
<span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">inherited</span><span class="p">(</span><span class="n">subclass</span><span class="p">)</span>
  <span class="c1"># ... truncated ...</span>
  <span class="no">Registry</span><span class="p">.</span><span class="nf">global</span><span class="p">.</span><span class="nf">enlist</span><span class="p">(</span><span class="n">subclass</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>It’s a callback method <a href="https://docs.ruby-lang.org/en/4.0/Class.html#method-i-inherited">“invoked whenever a subclass of the current class is created.”</a> When you create your own cop class, this method will be called.</p>

<p><a href="https://github.com/rubocop/rubocop/blob/dcbddbf07d36f316c3099397fb548e83ceb389d0/lib/rubocop/cop/registry.rb"><code class="language-plaintext highlighter-rouge">RuboCop::Cop::Registry</code></a> is a class that saves references to cop classes. It’s used in various places in RuboCop, which I’ll get to in a bit. You create an instance of a registry and start adding cops to it (with <code class="language-plaintext highlighter-rouge">#enlist</code>).</p>

<p><code class="language-plaintext highlighter-rouge">Registry.global</code> returns a global instance of the registry. You can, for example, call <code class="language-plaintext highlighter-rouge">RuboCop::Cop::Registry.global.cops</code> in the console to get a list of all cops.</p>

<p>So this callback adds a cop to the global registry whenever a cop class is created.</p>

<p>As was said, the registry is used in various places in RuboCop. Notably, <a href="https://github.com/rubocop/rubocop/blob/dcbddbf07d36f316c3099397fb548e83ceb389d0/lib/rubocop/runner.rb"><code class="language-plaintext highlighter-rouge">Runner</code></a> (which processes files for offenses) uses it to <a href="https://github.com/rubocop/rubocop/blob/ceb63776c63cefc7537f4d117b655daa4b2f0ce4/lib/rubocop/runner.rb#L485-L502">select cops to run based on <code class="language-plaintext highlighter-rouge">.rubocop.yml</code> and provided CLI options</a>. If we don’t load cop classes, they won’t be added to the registry, and then <code class="language-plaintext highlighter-rouge">Runner</code> won’t work properly.</p>

<p>The lazy-loading initiative solved this problem in two steps:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">Registry</code> has been refactored to support enlisting cops by stringified constant name. When the cop class is actually needed, <a href="https://github.com/rubocop/rubocop/blob/dcbddbf07d36f316c3099397fb548e83ceb389d0/lib/rubocop/cop/registry.rb#L414"><code class="language-plaintext highlighter-rouge">Kernel.const_get</code> is used to resolve the class</a>.</li>
  <li>A new method <a href="https://github.com/rubocop/rubocop/blob/dcbddbf07d36f316c3099397fb548e83ceb389d0/lib/rubocop/cop/lazy_loader.rb#L30"><code class="language-plaintext highlighter-rouge">register_cop</code></a> has been added to the API. It <code class="language-plaintext highlighter-rouge">autoload</code>s a cop class and adds the cop to the <code class="language-plaintext highlighter-rouge">Registry</code> as a stringified constant name.</li>
</ol>

<p>Here’s an example:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># lib/rubocop/cop/lint.rb</span>
<span class="k">module</span> <span class="nn">RuboCop</span>
  <span class="k">module</span> <span class="nn">Cop</span>
    <span class="k">module</span> <span class="nn">Lint</span>
      <span class="kp">extend</span> <span class="no">LazyLoader</span>

      <span class="n">register_cop</span> <span class="ss">:AmbiguousAssignment</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/lint/ambiguous_assignment"</span>
      <span class="c1"># other cops...</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>When <code class="language-plaintext highlighter-rouge">lib/rubocop/cop/lint.rb</code> is required, <code class="language-plaintext highlighter-rouge">register_cop</code> is called, which registers the cop <code class="language-plaintext highlighter-rouge">Lint/AmbiguousAssignment</code> under constant name <code class="language-plaintext highlighter-rouge">"RuboCop::Cop::Lint::AmbiguousAssignment"</code> in the global registry, and also <code class="language-plaintext highlighter-rouge">autoload</code>s the class (cop name and class name are the same).</p>

<p>Later, <code class="language-plaintext highlighter-rouge">Runner</code> accesses the cop from the registry, which calls <code class="language-plaintext highlighter-rouge">Kernel.const_get</code> to get the class, and <code class="language-plaintext highlighter-rouge">autoload</code> magic returns the <code class="language-plaintext highlighter-rouge">AmbiguousAssignment</code> class.</p>

<p>That is the gist of it. For in-depth details, please check out the PRs referenced in <a href="https://github.com/rubocop/rubocop/issues/14983">this issue</a>.</p>

<h2 id="show-me-the-numbers">Show Me the Numbers</h2>

<p>Before the numbers, here’s the Vernier profile on lazy-loading RuboCop for the same scenario as above (single cop on just one file):</p>

<p><img src="/images/rubocop_after.png" width="100%" /></p>

<p>This is what we were aiming for, reducing the number of icicles.</p>

<p>Now for numbers. As was said above, speedup depends on the number of running cops. For simplicity, <a href="https://gist.github.com/lovro-bikic/f3236a121501243d254a84e3d7db739f">I’ve benchmarked</a> all runs on a single Ruby file (multiple files naturally take longer) with caching disabled. All <em>before</em> and <em>after</em> runs have been repeated 30 times. Each run will display average execution time with standard deviation.</p>

<p>I’ll show you three RuboCop scenarios, in the order of most to fewest running cops:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">rubocop</code> (<a href="https://github.com/standardrb/standard">Standard</a> config)</li>
  <li><code class="language-plaintext highlighter-rouge">rubocop --only [department]</code></li>
  <li><code class="language-plaintext highlighter-rouge">rubocop --only [cop]</code></li>
</ol>

<p><em>After</em> runs are on commit <a href="https://github.com/rubocop/rubocop/commit/e9defb651dff92627ac49ddca76211011c7cf08c">e9defb6</a> (when lazy-loading was added); <em>before</em> runs are on <a href="https://github.com/rubocop/rubocop/commit/8dc65e7705064ff404847fa98778df50efbb334b">8dc65e7</a> (the one before lazy-loading).</p>

<h3 id="1-rubocop-standard-config">1. <code class="language-plaintext highlighter-rouge">rubocop</code> (Standard config)</h3>

<p>For this setup, I configured RuboCop with <a href="https://github.com/standardrb/standard/blob/v1.56.0/config/base.yml">Standard base config</a> (353 enabled cops) and ran <code class="language-plaintext highlighter-rouge">bundle exec rubocop --cache false</code>.</p>

<p>Before: 1.650 ± 0.022 s<br />
After: 1.473 ± 0.018 s</p>

<p>Execution time change: <strong>-10.73%</strong></p>

<h3 id="2-rubocop---only-department">2. <code class="language-plaintext highlighter-rouge">rubocop --only [department]</code></h3>

<p>Similar setup to above (no Standard installed). I chose the Layout department. Around 100 cops ran.</p>

<p>Before: 1.578 ± 0.040 s<br />
After: 1.280 ± 0.061 s</p>

<p>Execution time change: <strong>-18.88%</strong></p>

<h3 id="3-rubocop---only-cop">3. <code class="language-plaintext highlighter-rouge">rubocop --only [cop]</code></h3>

<p>Similar setup to above. I chose the <code class="language-plaintext highlighter-rouge">Style/HashSlice</code> cop.</p>

<p>Before: 1.554 ± 0.017 s<br />
After: 1.138 ± 0.019 s</p>

<p>Execution time change: <strong>-26.77%</strong></p>

<h3 id="interpretation">Interpretation</h3>

<p>While the first <em>before</em> run is the slowest one, all <em>before</em> runs share similar execution times. This comes down to the fact that for a single file, most of the execution time is spent on startup rather than processing the file for offenses.</p>

<p><em>After</em> runs have different times depending on the number of running cops. Fewer cops correlate with better times. The logical conclusion is that the best RuboCop times are achieved with all cops disabled.</p>

<p>Standard is a very popular config, so I would say that ~10% improvement in startup time is the realistic win of this initiative. But please note that for many processed files, the improvement won’t be as visible because the time spent processing files will overshadow startup times.</p>

<h2 id="how-to-migrate-to-the-new-api">How To Migrate to the New API</h2>

<p>Lazy-loading was only added to RuboCop itself, but plugins will still <code class="language-plaintext highlighter-rouge">require</code> cops unless they too migrate to the new API. <a href="https://github.com/rubocop/rubocop/blob/v1.89.0/docs/modules/ROOT/pages/development.adoc#cop-lazy-loading">RuboCop’s documentation has all the details on lazy-loading</a>, so I’ll just briefly demonstrate how to migrate away from <code class="language-plaintext highlighter-rouge">require</code>.</p>

<p>Whereas before you’d have a file to require all cops:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># lib/rubocop.rb</span>
<span class="nb">require_relative</span> <span class="s1">'rubocop/cop/bundler/duplicated_gem'</span>
<span class="nb">require_relative</span> <span class="s1">'rubocop/cop/bundler/duplicated_group'</span>
<span class="nb">require_relative</span> <span class="s1">'rubocop/cop/bundler/gem_comment'</span>
<span class="nb">require_relative</span> <span class="s1">'rubocop/cop/bundler/gem_filename'</span>
<span class="nb">require_relative</span> <span class="s1">'rubocop/cop/bundler/gem_version'</span>
<span class="nb">require_relative</span> <span class="s1">'rubocop/cop/bundler/insecure_protocol_source'</span>
<span class="nb">require_relative</span> <span class="s1">'rubocop/cop/bundler/ordered_gems'</span></code></pre></figure>

<p>You can now use the <code class="language-plaintext highlighter-rouge">register_cop</code> method to register the cops for lazy-loading in the department namespace:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># lib/rubocop/cop/bundler.rb</span>
<span class="k">module</span> <span class="nn">RuboCop</span>
  <span class="k">module</span> <span class="nn">Cop</span>
    <span class="k">module</span> <span class="nn">Bundler</span>
      <span class="kp">extend</span> <span class="no">LazyLoader</span>

      <span class="n">register_cop</span> <span class="ss">:DuplicatedGem</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/bundler/duplicated_gem"</span>
      <span class="n">register_cop</span> <span class="ss">:DuplicatedGroup</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/bundler/duplicated_group"</span>
      <span class="n">register_cop</span> <span class="ss">:GemComment</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/bundler/gem_comment"</span>
      <span class="n">register_cop</span> <span class="ss">:GemFilename</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/bundler/gem_filename"</span>
      <span class="n">register_cop</span> <span class="ss">:GemVersion</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/bundler/gem_version"</span>
      <span class="n">register_cop</span> <span class="ss">:InsecureProtocolSource</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/bundler/insecure_protocol_source"</span>
      <span class="n">register_cop</span> <span class="ss">:OrderedGems</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">__dir__</span><span class="si">}</span><span class="s2">/bundler/ordered_gems"</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>That’s it, your plugin is now ready for lazy-loading.</p>

<p>Three things to note:</p>

<ul>
  <li>set minimum RuboCop version to <code class="language-plaintext highlighter-rouge">1.89.0</code> in the gemspec because of the new API</li>
  <li>add <code class="language-plaintext highlighter-rouge">extend LazyLoader</code> to the module so you can use <code class="language-plaintext highlighter-rouge">register_cop</code></li>
  <li>provide an absolute path to the cop file in the second <code class="language-plaintext highlighter-rouge">register_cop</code> argument (hence the use of <code class="language-plaintext highlighter-rouge">__dir__</code>)</li>
</ul>

<p>Depending on your plugin, there might be some other details to take into consideration. As a practical example, take a look at <a href="https://github.com/rubocop/rubocop-rails/pull/1650">this PR</a> that added lazy-loading to <code class="language-plaintext highlighter-rouge">rubocop-rails</code>.</p>

<p>I encourage plugin authors to use the new API so we can all reap the benefits of faster RuboCop.</p>

<h2 id="thanks-department">Thanks Department</h2>

<p>I got inspiration for this initiative when I saw <a href="https://github.com/rubocop/rubocop/issues/14732">this issue</a> by <a href="https://github.com/byroot">Jean Boussier</a> that included an idea to lazy load cops. While he started in the right direction, his proof of concept probably got stuck when it came to dealing with the cop registry and other bits. Nevertheless, it inspired me to give it a try. Thanks Jean!</p>

<p>After the first couple of PRs were merged, <a href="https://github.com/koic">Koic</a> took over and <a href="https://github.com/rubocop/rubocop/pull/15436">did gargantuan work</a> to finish everything. I’m super happy this happened because it sped up the most tedious part of the initiative. Thanks Koic!</p>

<p>Last but not least, thanks to RuboCop creator Bozhidar Batsov for being on board with the whole thing — it means a lot!</p>

<p>That’s all, thanks for reading!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Recap of an initiative that improved RuboCop startup times.]]></summary></entry><entry><title type="html">rspec-mockbidden: Forbid Unwanted RSpec Mocks</title><link href="https://lovro-bikic.github.io/rspec-mockbidden/" rel="alternate" type="text/html" title="rspec-mockbidden: Forbid Unwanted RSpec Mocks" /><published>2026-04-19T00:00:00+00:00</published><updated>2026-04-19T00:00:00+00:00</updated><id>https://lovro-bikic.github.io/rspec-mockbidden</id><content type="html" xml:base="https://lovro-bikic.github.io/rspec-mockbidden/"><![CDATA[<p>Proliferation of AI-generated code has caused a surge in mocks across test suites.</p>

<p>Some mocks I’ve seen have terrorized me:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">let</span><span class="p">(</span><span class="ss">:address</span><span class="p">)</span> <span class="p">{</span> <span class="no">Hash</span><span class="p">.</span><span class="nf">new</span> <span class="p">}</span>

<span class="n">before</span> <span class="k">do</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">address</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:fetch</span><span class="p">).</span><span class="nf">with</span><span class="p">(</span><span class="ss">:country</span><span class="p">).</span><span class="nf">and_return</span><span class="p">(</span><span class="s1">'HR'</span><span class="p">)</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">address</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:fetch</span><span class="p">).</span><span class="nf">with</span><span class="p">(</span><span class="ss">:province</span><span class="p">).</span><span class="nf">and_return</span><span class="p">(</span><span class="s1">'Grad Zagreb'</span><span class="p">)</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">address</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:fetch</span><span class="p">).</span><span class="nf">with</span><span class="p">(</span><span class="ss">:postal_code</span><span class="p">).</span><span class="nf">and_return</span><span class="p">(</span><span class="s1">'10000'</span><span class="p">)</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">address</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:fetch</span><span class="p">).</span><span class="nf">with</span><span class="p">(</span><span class="ss">:city</span><span class="p">).</span><span class="nf">and_return</span><span class="p">(</span><span class="s1">'Zagreb'</span><span class="p">)</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">address</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:fetch</span><span class="p">).</span><span class="nf">with</span><span class="p">(</span><span class="ss">:street</span><span class="p">).</span><span class="nf">and_return</span><span class="p">(</span><span class="s1">'Trg Republike Hrvatske'</span><span class="p">)</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">address</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:fetch</span><span class="p">).</span><span class="nf">with</span><span class="p">(</span><span class="ss">:house_number</span><span class="p">).</span><span class="nf">and_return</span><span class="p">(</span><span class="s1">'15'</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>When I get this kind of slop on a pull request, I don’t mind explaining there’s an easier way to construct a hash:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">let</span><span class="p">(</span><span class="ss">:address</span><span class="p">)</span> <span class="k">do</span>
  <span class="p">{</span>
    <span class="ss">country: </span><span class="s1">'HR'</span><span class="p">,</span>
    <span class="ss">province: </span><span class="s1">'Grad Zagreb'</span><span class="p">,</span>
    <span class="o">...</span>
  <span class="p">}</span>
<span class="k">end</span></code></pre></figure>

<p>but, you know, repeating the same thing over and over again gets boring really fast.</p>

<p><a href="https://github.com/lovro-bikic/rspec-mockbidden"><code class="language-plaintext highlighter-rouge">rspec-mockbidden</code></a> is a little gem that forbids such mocks for me:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="no">RSpec</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">before</span> <span class="k">do</span>
    <span class="n">forbid_any_instance_of</span><span class="p">(</span><span class="no">Hash</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="n">anything</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>Now every time someone tries mocking a method on a <code class="language-plaintext highlighter-rouge">Hash</code>, their test fails with an error:</p>

<p><code class="language-plaintext highlighter-rouge">Hash is forbidden from mocking any instance method</code></p>

<p>AI, however, is not the reason I created this gem; it’s merely what pushed me to create it. The reason I created it is because, on codebases of a certain size, you need to communicate what is acceptable to mock and what should always execute.</p>

<p>For example, on the Rails codebase I’m working on, we use <code class="language-plaintext highlighter-rouge">FactoryBot</code> to set up test data with <code class="language-plaintext highlighter-rouge">create</code> and <code class="language-plaintext highlighter-rouge">build</code> methods. Code like this is not acceptable:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">before</span> <span class="k">do</span>
  <span class="n">allow</span><span class="p">(</span><span class="no">User</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:find</span><span class="p">).</span><span class="nf">and_return</span><span class="p">(</span><span class="n">instance_double</span><span class="p">(</span><span class="s1">'User'</span><span class="p">,</span> <span class="ss">first_name: </span><span class="s1">'Lovro'</span><span class="p">))</span>
<span class="k">end</span></code></pre></figure>

<p>If we already have a database available in our test suite, we should use it (it will be used in production anyway, and that’s the reality we want to simulate in tests). If we don’t need database access in a test, <code class="language-plaintext highlighter-rouge">build</code> does the job.<sup><sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></sup></p>

<p><code class="language-plaintext highlighter-rouge">rspec-mockbidden</code> puts a stop to this:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">before</span> <span class="k">do</span>
  <span class="n">forbid</span><span class="p">(</span><span class="no">ApplicationRecord</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="ss">:find</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>Mocking <code class="language-plaintext highlighter-rouge">.find</code> is now forbidden on <code class="language-plaintext highlighter-rouge">ApplicationRecord</code> or any of its subclasses.</p>

<p>If needed, mocking all class methods on <code class="language-plaintext highlighter-rouge">ApplicationRecord</code> can be forbidden as well:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">before</span> <span class="k">do</span>
  <span class="n">forbid</span><span class="p">(</span><span class="no">ApplicationRecord</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="n">anything</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>It also works the other way around, forbidding a specific method on all classes:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">before</span> <span class="k">do</span>
  <span class="n">forbid</span><span class="p">(</span><span class="n">anything</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="ss">:create!</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>For fun, you can also <code class="language-plaintext highlighter-rouge">forbid(anything).from receiving(anything)</code>, I won’t stop you (though it’s probably not realistic since mocks are sometimes valid).</p>

<p>I should note that when <code class="language-plaintext highlighter-rouge">.and_call_original</code> is used on a mock, an error won’t be raised. This, for example, is perfectly acceptable:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">before</span> <span class="k">do</span>
  <span class="n">forbid</span><span class="p">(</span><span class="no">ApplicationRecord</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="ss">:first</span><span class="p">)</span>
<span class="k">end</span>

<span class="n">it</span> <span class="s1">'loads the first user only once'</span> <span class="k">do</span>
  <span class="n">allow</span><span class="p">(</span><span class="no">User</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:first</span><span class="p">).</span><span class="nf">and_call_original</span>

  <span class="no">FirstUserService</span><span class="p">.</span><span class="nf">call</span>

  <span class="n">expect</span><span class="p">(</span><span class="no">User</span><span class="p">).</span><span class="nf">to</span> <span class="n">have_received</span><span class="p">(</span><span class="ss">:first</span><span class="p">).</span><span class="nf">once</span>
<span class="k">end</span></code></pre></figure>

<p><code class="language-plaintext highlighter-rouge">forbid</code> can be added anywhere in the test lifecycle:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">it</span> <span class="s1">'forbids in the test'</span> <span class="k">do</span>
  <span class="c1"># valid only for the duration of this test</span>
  <span class="n">forbid</span><span class="p">(</span><span class="no">User</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="ss">:first</span><span class="p">)</span>
<span class="k">end</span>

<span class="c1"># valid for any test where this hook is applied</span>
<span class="n">before</span> <span class="k">do</span>
  <span class="n">forbid</span><span class="p">(</span><span class="n">anything</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="ss">:call</span><span class="p">)</span>
<span class="k">end</span>

<span class="c1"># mocking `ApplicationRecord#save` is forbidden for all tests in the suite</span>
<span class="c1"># this is probably a good place for suite-wide rules</span>
<span class="n">before</span><span class="p">(</span><span class="ss">:suite</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">forbid_any_instance_of</span><span class="p">(</span><span class="no">ApplicationRecord</span><span class="p">).</span><span class="nf">from</span> <span class="n">receiving</span><span class="p">(</span><span class="ss">:save</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p><a href="https://github.com/lovro-bikic/rspec-mockbidden#rspecmockbidden">The README</a> has more implementation details and examples, including installation instructions.</p>

<p>The gem has been released today as <a href="https://rubygems.org/gems/rspec-mockbidden/versions/0.1.0">v0.1.0</a>. It has been tested on two production codebases, but it’s still an early version with a limited API. The API might be extended or changed, depending on real-world usage (e.g., forbidding multiple methods at once).</p>

<p>Feedback and contributions are welcome at <a href="https://github.com/lovro-bikic/rspec-mockbidden">https://github.com/lovro-bikic/rspec-mockbidden</a></p>

<p>I’m interested in hearing which mocks people want to forbid from their codebase.</p>

<p>Enjoy!</p>

<p><br /></p>

<h4 id="footnotes">Footnotes</h4>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="/factory-bot-build-without-creating">You might be interested in knowing how to ensure this really is the case.</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[Utility gem for RSpec testing framework to forbid mocking methods on objects/classes/modules, evaluated at test runtime.]]></summary></entry><entry><title type="html">300 Days of RuboCop</title><link href="https://lovro-bikic.github.io/300-days-of-rubocop/" rel="alternate" type="text/html" title="300 Days of RuboCop" /><published>2026-02-20T00:00:00+00:00</published><updated>2026-02-20T00:00:00+00:00</updated><id>https://lovro-bikic.github.io/300-days-of-rubocop</id><content type="html" xml:base="https://lovro-bikic.github.io/300-days-of-rubocop/"><![CDATA[<blockquote>
  <p>It all moves along, however crowded, quite steadily at the rate of 25 miles per hour (Terran). Gethenians could make their vehicles go faster, but they do not. If asked why not, they answer “Why?” Like asking Terrans why all our vehicles must go so fast; we answer “Why not?” No disputing tastes.</p>
</blockquote>

<p>— Ursula K. Le Guin, The Left Hand of Darkness</p>

<p><br /></p>

<p>Hi, my name is Lovro and I spent 300 days adding a linter to a legacy codebase with one million lines of code.</p>

<p>What follows is an account of my initiative to add RuboCop to a Ruby on Rails codebase. I’ll talk about what set it off, my RuboCop proposal to management, why it got approved, my approach to enabling cops and fixing offenses, how I started contributing back to RuboCop, how others started contributing to the initiative, about fast and right solutions to problems, the opinions I gathered along the way, and, finally, when it was time to call it quits.</p>

<p>But I didn’t know any of that when I started.</p>

<p>I just knew there was a pull request, and my review had just been requested.</p>

<h2 id="weve-been-here-before">We’ve Been Here Before</h2>

<p>I’m not shy when it comes to PR reviews. Incorrect code, performance optimizations, refactoring, simplification, stylistic nitpicks, poor grammar in documentation — all are equally deserving of a comment. When there’s an opportunity for educating the author (common for juniors’ PRs), I will jump on it. Sometimes, I also message the author with links to articles I think they could read.</p>

<p>One time, I left 230 comments on a PR. Nobody was happy about it, not me certainly, but I did tell the author many times before that big features need to be split into multiple PRs to make everyone’s life easier. Despite my pleas for best practices, still I was requested to review 7k lines of code at once. Well, I haven’t gotten a PR like that since.</p>

<p>Fast forward 5 years, I’m working on a different project in a big team. A massive legacy Rails codebase, code name Chaotic Beauty. Bugs cannot escape its gravitational field.</p>

<p>One day, a recently joined developer added a small-ish feature, and the PR link came my way. I opened it. I went through it once, scrolled to the top and started writing.</p>

<p>There were a number of issues with it. It was not unexpected; the dev was new to Ruby. I saw it as an educational opportunity for both parties. Out of the comments I left, I’ll highlight two pertinent ones.</p>

<p>The first one was for a unit test:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># spec/lib/tasks/users/employees_backfill_education_degree.rb</span>
<span class="no">RSpec</span><span class="p">.</span><span class="nf">describe</span> <span class="no">Tasks</span><span class="o">::</span><span class="no">Users</span><span class="o">::</span><span class="no">SetEmployeeEducationLevel</span> <span class="k">do</span>
  <span class="c1"># tests go here</span>
<span class="k">end</span></code></pre></figure>

<p>This test’s unit lives in <code class="language-plaintext highlighter-rouge">lib/tasks/users/</code><strong><code class="language-plaintext highlighter-rouge">set_employee_education_level</code></strong><code class="language-plaintext highlighter-rouge">.rb</code>. Notice how the test path doesn’t match the unit path. If I search for “set employee education level” in my editor, I’ll find the unit, but not the test. The path is not intuitive. When this anti-pattern scales to thousands of files, good luck navigating the codebase.</p>

<p>The second one was for a test variable, which initially looked like this:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">let</span> <span class="p">(</span><span class="ss">:user</span><span class="p">)</span> <span class="p">{</span>
  <span class="n">create</span><span class="p">(</span><span class="ss">:user</span><span class="p">)</span>
<span class="p">}</span></code></pre></figure>

<p>I commented that <code class="language-plaintext highlighter-rouge">create(:user)</code> can be on the same line as <code class="language-plaintext highlighter-rouge">let</code> (3 lines are not needed for simple record setup):</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">let</span><span class="p">(</span><span class="ss">:user</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:user</span><span class="p">)</span> <span class="p">}</span></code></pre></figure>

<p>He replied that the “syntax formatter is forcing this style”. That was true, but it was forcing it only because the dev put a whitespace between <code class="language-plaintext highlighter-rouge">let</code> and <code class="language-plaintext highlighter-rouge">(:user)</code>, <a href="https://rubystyle.guide/#parens-no-spaces">which is not preferred</a>. It’s a method call, after all. Once the whitespace was removed, the suggested one-liner was allowed by the formatter.</p>

<p>There were other similar comments. We racked up 82 in total (“a few”, I think I said). I estimate my reviewing time was around an hour and a half. I guess it took the dev a bit more than that. And we both probably took a long coffee break afterwards.</p>

<p>In this estimation, I didn’t even account for that feeling of lost time that lingers even when the coffee break is over and you need to move on with your day. The situation got me thinking: should I propose RuboCop, <em>again</em>?</p>

<div style="text-align: center">* * *</div>

<p>I had proposed RuboCop the first time about a year before. I suggested it to a staff engineer because we only had a syntax formatter at the time (and RuboCop is also a linter), but he was vehemently opposed to the idea. I can’t recall his arguments.</p>

<p>Weeks after the exchange, the same SE figured out we needed to replace the syntax formatter, which was <a href="https://github.com/prettier/plugin-ruby">Ruby plugin for Prettier</a>. This time around, a colleague of mine mentioned RuboCop, but authority still wasn’t swayed. Something about how he (SE) didn’t have experience with it, and how he first wanted to turn on syntax formatting without linting (he wasn’t aware RuboCop supports that), and that too much time was spent already selecting the new tool.</p>

<p>First we switched to <a href="https://github.com/ruby-formatter/rufo">Rufo</a>, then 3 weeks later we switched to <a href="https://github.com/ruby-syntax-tree/syntax_tree">SyntaxTree</a>. The switch required a code freeze and a massive commit to restyle the codebase GitHub would prefer you wouldn’t open in the UI. It also caused collateral damage in the form of merge conflicts for the many open PRs, of which there were 150+ at the time (at any time, really).</p>

<p>Side note: you might like to know that Ruby plugin for Prettier is only a wrapper around SyntaxTree, meaning we ended up pretty much exactly where we began, with a little road bump in between. There is a guy pushing a rock up a hill who is very impressed by this.</p>

<p>I’m sure the ordeal didn’t imprint itself as a happy memory on anybody, and I wouldn’t have been thinking about switching formatters again were it not for the SE’s departure from the company, some time after the ordeal.</p>

<p>I saw the window of opportunity. Other ears would listen now, what could I possibly lose?</p>

<h2 id="roi-nice-to-meet-you">ROI, Nice To Meet You</h2>

<p>I set up a channel (<code class="language-plaintext highlighter-rouge">#rubocop-in-monolith</code>), invited staff engineers and the engineering manager, then started writing. There was no plan, I let it structure itself naturally as I wrote. My first sentence was that I invited everyone because I wanted to open the discussion about enforcing RuboCop in the monolith.</p>

<p>The argument began by pointing out that we only have a syntax formatter, with which <em>“you can still make tons of mistakes and all it will do is format them nicely”</em>, as I wrote. As a matter of fact, it also adds trailing commas and forces single quotation marks for strings, but still, it’s not a linter.</p>

<p>I continued by claiming we hastily switched formatters (linking to relevant discussions), and that we keep switching them because they’re too simple and barely customizable (meaning, they have few rules, which can rarely be modified or disabled), something not acceptable for a monolith our size.</p>

<p><em>“As another point, and I’m sure you’re aware of this, there’s a fair amount of non-Ruby devs working on the monolith. Syntax formatting is not enough for them. It’s not enough even for me, and I’ve been writing Ruby almost exclusively for the past 5+ years.”</em> I linked to the 82-comments PR, pointed out that only a minority of comments touched upon the business logic (yes, I’m partly to blame here), and also mentioned the amount of time it took to review it.</p>

<p>Why RuboCop? Because:</p>
<ul>
  <li>it’s a syntax formatter <em>and</em> linter, with <a href="https://github.com/rubocop/rubocop/blob/master/config/default.yml">hundreds of available rules</a> (known as cops),</li>
  <li>there are plugins with additional cops for different domains (plugin for <a href="https://github.com/rubocop/rubocop-rails/">Rails cops</a>, for <a href="https://github.com/rubocop/rubocop-rspec">RSpec cops</a>, for <a href="https://github.com/rubocop/rubocop-factory_bot/">FactoryBot cops</a>, etc.),</li>
  <li>it’s a <a href="https://www.ruby-toolbox.com/categories/code_metrics">de-facto standard</a> in the Ruby community for static code analysis,</li>
  <li>it’s used by big players like <a href="https://github.com/Shopify/ruby-style-guide">Shopify</a>, <a href="https://github.com/github/rubocop-github">GitHub</a>, <a href="https://github.com/airbnb/ruby">Airbnb</a>, <a href="https://github.com/discourse/rubocop-discourse">Discourse</a>, <a href="https://github.com/alphagov/rubocop-govuk">The Government of UK</a>, and so on,</li>
  <li>it’s used for Ruby on Rails itself,</li>
  <li>we can write custom cops to enforce rules specific to our domain,</li>
  <li>it’s highly configurable (each cop can be disabled, and cops typically have configuration options),</li>
  <li>it can be introduced gradually, cop-by-cop, with minimum friction (no code freezes and minimum merge conflicts),</li>
  <li>it can co-exist with the current formatter until RuboCop is ready to take over.</li>
</ul>

<p>I should have (but didn’t at the time) linked to specific cops like <a href="https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecspecfilepathformat"><code class="language-plaintext highlighter-rouge">RSpec/SpecFilePathFormat</code></a> and <a href="https://docs.rubocop.org/rubocop/cops_lint.html#lintparenthesesasgroupedexpression"><code class="language-plaintext highlighter-rouge">Lint/ParenthesesAsGroupedExpression</code></a> that would have actually made my comments for unit test path and the test variable in that PR obsolete, thereby directly showing how RuboCop can save time.</p>

<p>I looked over what I wrote a couple of times, then clicked Send.</p>

<div style="text-align: center">* * *</div>

<p>There is a presence in most stakeholder meetings. It takes many forms. Newcomers might be oblivious to it; others understand it instinctively. It manifests itself as employee productivity, time savings, customer retention, cost optimization… just to name a few. It is the life and death of technical initiatives. It is the invisible stakeholder with power of veto. It has a name too. We call it Return on Investment.</p>

<p>The technical crowd in the channel provided valid counterpoints for RuboCop. Its high configurability is a double edged sword because it can introduce bikeshedding, and requires strong ownership. False positives are not uncommon. It can pollute Git history (<a href="https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-filefile">ignore revs file</a> will help here). Some cop departments are more useful than others. We debated, more out of pure technical interest than anything else.</p>

<p>But out of everything I wrote, one point struck a major nerve: why are developers spending their time on things on PRs that can be resolved with tools, and not focusing on business logic? I was asked to estimate the effort of adding RuboCop.</p>

<p>The discussion continued for a while longer.</p>

<p>Stakeholders said they’ll get back with a decision. Two weeks later, it was approved. The scope was defined, I was designated the owner of the initiative, and work could begin. The feeling was a mixture of happy and anxious. I’m not shortsighted, this thing will obviously take a lot of effort. I have no idea what’s waiting for me.</p>

<p>Would I have succeeded had the staff engineer still been here? For one, it would have been rude to go behind his back and propose RuboCop a third time within a year, and this time to his leads. Alas. There’s a <a href="https://en.wikipedia.org/wiki/Planck%27s_principle">paraphrase of Max Planck</a> that science progresses one funeral at a time; I guess a company’s unit of progress is a senior departure.<sup><sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></sup></p>

<h2 id="this-mess-were-in">This Mess We’re In</h2>

<p>I added RuboCop and set up the CI workflow. The initial configuration looked like this:</p>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml"><span class="c1"># .rubocop.yml</span>
<span class="na">AllCops</span><span class="pi">:</span>
  <span class="na">NewCops</span><span class="pi">:</span> <span class="s">enable</span>
  <span class="na">DisabledByDefault</span><span class="pi">:</span> <span class="no">true</span></code></pre></figure>

<p>The plan was to work on <a href="https://docs.rubocop.org/rubocop/cops_lint.html">Lint department</a> first, arguably the set of cops with the most useful rules. The claim will be substantiated shortly.</p>

<p>I opted to fix offenses not by auto-generating a <a href="https://docs.rubocop.org/rubocop/configuration.html#automatically-generated-configuration"><code class="language-plaintext highlighter-rouge">.rubocop_todo.yml</code></a> file, but by enabling or disabling one cop at a time from an empty config. There were no plans to mass fix multiple cops in huge PRs, even cops with safe autocorrect.</p>

<p>You can generate a report with the count of offenses per cop by running <code class="language-plaintext highlighter-rouge">rubocop --format offenses</code> (just make sure to comment out <code class="language-plaintext highlighter-rouge">DisabledByDefault</code>). The file is sorted by count, max offenses at the top. I worked from the bottom. Starting with cops that have only 1 or 2 offenses prevents one from being overwhelmed; that the first report had offenses for 450+ cops was overwhelming enough.</p>

<p>With the report in hand, I started fixing. Come with me on a road trip through my first PRs:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># offense for Lint/BinaryOperatorWithIdenticalOperands</span>
<span class="k">if</span> <span class="n">address</span><span class="p">.</span><span class="nf">lng</span><span class="p">.</span><span class="nf">nil?</span> <span class="o">||</span> <span class="n">address</span><span class="p">.</span><span class="nf">lng</span><span class="p">.</span><span class="nf">nil?</span>
  <span class="o">...</span>
<span class="k">end</span>

<span class="c1"># the author probably wanted to check if `address.lat` is nil too</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/EmptyWhen</span>
<span class="k">case</span> <span class="n">foo</span>
<span class="k">when</span> <span class="no">BAR</span><span class="p">,</span>
     <span class="n">do_this_please</span>
<span class="k">end</span>

<span class="c1"># the comma should not be there, `do_this_please` was intended to be executed when `foo` matched `BAR`</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/DuplicateRescueException</span>
<span class="k">begin</span>
  <span class="n">something</span>
<span class="k">rescue</span> <span class="no">StandardError</span>
  <span class="n">handle_error</span>
<span class="k">rescue</span> <span class="no">StandardError</span>
  <span class="n">handle_error</span>
<span class="k">end</span>

<span class="c1"># probably a result of a poorly handled merge conflict</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/InterpolationCheck</span>
<span class="n">context</span> <span class="s1">'when state is #{state}'</span> <span class="k">do</span>
  <span class="o">...</span>
<span class="k">end</span>

<span class="c1"># false positive if state really is '#{state}'</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/RedundantSafeNavigation</span>
<span class="no">SomeModule</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">foo?</span>

<span class="c1"># why did someone think `SomeModule` could be nil?</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/Void</span>
<span class="k">def</span> <span class="nf">items</span>
  <span class="p">[]</span> <span class="k">if</span> <span class="no">DECLINED_STATUSES</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">status</span><span class="p">)</span>

  <span class="k">super</span>
<span class="k">end</span>

<span class="c1"># the author intended the first line to be a guard clause, i.e. `return []`</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/UnreachableCode</span>
<span class="k">def</span> <span class="nf">filter_discounts</span><span class="p">(</span><span class="n">discounts</span><span class="p">)</span>
  <span class="k">return</span> <span class="n">discounts</span>

  <span class="n">discounts</span><span class="p">.</span><span class="nf">select</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:applicable?</span><span class="p">)</span>
<span class="k">end</span>

<span class="c1"># a quick business decision determined all discounts are applicable</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/DuplicateMethods</span>
<span class="k">class</span> <span class="nc">Foo</span>
  <span class="k">def</span> <span class="nf">bar</span>
    <span class="n">does_something</span>
  <span class="k">end</span>

  <span class="c1"># ...200 lines later...</span>

  <span class="k">def</span> <span class="nf">bar</span>
    <span class="n">does_something_else</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># once again, probably a poorly handled merge conflict</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/SafeNavigationChain</span>
<span class="n">user</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">address</span><span class="p">.</span><span class="nf">country</span>

<span class="c1"># if `user` is nil, `.country` will raise `NoMethodError`</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># [insert offense for Lint/UselessAssignment]</span>
<span class="c1"># so many variables assigned but never used... it took 9 PRs to remove them all</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/ConstantDefinitionInBlock</span>
<span class="no">RSpec</span><span class="p">.</span><span class="nf">describe</span> <span class="no">Foo</span> <span class="k">do</span>
  <span class="no">MAX_RUNS</span> <span class="o">=</span> <span class="mi">4</span>
<span class="k">end</span>

<span class="c1"># while it might not look like it, `MAX_RUNS` is in fact a global constant</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/ConstantReassignment</span>
<span class="k">class</span> <span class="nc">Foo</span>
  <span class="no">BAR</span> <span class="o">=</span> <span class="ss">:bar</span>
  <span class="c1"># couple of lines later</span>
  <span class="no">BAR</span> <span class="o">=</span> <span class="ss">:bar</span>
<span class="k">end</span></code></pre></figure>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># Lint/SuppressedException</span>
<span class="k">def</span> <span class="nf">fetch_first_key</span><span class="p">(</span><span class="nb">hash</span><span class="p">,</span> <span class="o">*</span><span class="n">keys</span><span class="p">)</span>
  <span class="n">keys</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">key</span><span class="o">|</span>
    <span class="k">begin</span>
      <span class="k">return</span> <span class="nb">hash</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
    <span class="k">rescue</span> <span class="no">KeyError</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># exceptions used for control flow, a classic</span></code></pre></figure>

<p>Turns out, we needed a linter after all.</p>

<p>As I was fixing offenses, I also publicly documented PRs which fixed bugs. One has to feed ROI.</p>

<p>The work quickly turned into a routine. Turn a cop on, fix offenses, then pick another cop. Like an old man doing crosswords. And I would more than happily have continued emptying the report like this, had I not suddenly come to a halt.</p>

<h2 id="the-first-fork-in-the-road">The First Fork in the Road</h2>

<p>One day, I enabled <a href="https://docs.rubocop.org/rubocop/cops_lint.html#lintfloatcomparison"><code class="language-plaintext highlighter-rouge">Lint/FloatComparison</code></a>. It warns you when you use floats in (in)equality comparison because it’s unreliable (due to precision loss). But then it reported an offense for this line of code:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="no">Float</span><span class="p">(</span><span class="n">latitude</span><span class="p">,</span> <span class="ss">exception: </span><span class="kp">false</span><span class="p">)</span> <span class="o">==</span> <span class="kp">nil</span></code></pre></figure>

<p>That didn’t seem right, it only checks if variable <code class="language-plaintext highlighter-rouge">latitude</code> is an invalid float string. I didn’t use <a href="https://docs.ruby-lang.org/en/4.0/Kernel.html#method-i-Float"><code class="language-plaintext highlighter-rouge">Kernel#Float</code> method</a> before, but I ran it in the console and sure enough, given a string that cannot be parsed as a float, the left hand side of the expression returns <code class="language-plaintext highlighter-rouge">nil</code>. This code is correct.</p>

<p>Unknown to me at the time, this was a fork in the road.</p>

<p>The easy solution was this:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="no">Float</span><span class="p">(</span><span class="n">latitude</span><span class="p">,</span> <span class="ss">exception: </span><span class="kp">false</span><span class="p">).</span><span class="nf">nil?</span></code></pre></figure>

<p>Refactor and move on. It’s even idiomatic Ruby. But that didn’t sit right with me.</p>

<p>It didn’t sit right with me because there might be more similar offenses in existing code (there were) or some dev might add similar code in the future and then be confused by the offense (wasting billed time, which does not appease ROI). But ROI aside, here’s the bigger picture: everyone who uses RuboCop will have devs likewise confused by the offense. Some might even think they’re doing something wrong.</p>

<p>I stared at the offense, and it stared back at me.</p>

<p>It was really hard <em>not</em> to look into the RuboCop source code — which I’ve never opened before — now being motivated by curiosity, economics, and a budding sense of accomplishment.</p>

<p><a href="https://github.com/rubocop/rubocop/pull/13432">So I looked</a>. I even said Hi! on my first PR, I think it’s good manners.</p>

<p>The first time is always the hardest. After searching GitHub issues and not finding mine, I forked the repo. RuboCop uses RSpec for its test suite, so that was familiar to me. I copied one of the existing tests for the cop and modified it to fit my scenario. It failed, as expected.</p>

<p>Then the hard part. Suffice it to say, after one too many breakpoints, I finally figured out the solution. Happy with it, I ran all checks and they passed. I don’t know why, but I was so nervous to open the PR that I re-read my patch at least ten times! Grammar, logic, formatting, PR checklist — I didn’t want to mess anything up. And it was only a few lines of code. When I finally realized I was being ridiculous, I clicked Open.</p>

<p>It was merged 65 minutes later. And I got a “Thank you!” What a dopamine hit. Followed by another, a few weeks later, when the PR was mentioned in <a href="https://github.com/rubocop/rubocop/releases/tag/v1.69.0">v1.69.0 release notes</a>.</p>

<p>After the first hit, I got interested. And with hundreds of cops still left to enable on a gargantuan project, it was the perfect environment to start an avalanche of contributions. But I will cover the lessons learned from the <a href="https://github.com/search?q=org%3Arubocop%20author%3Alovro-bikic&amp;type=pullrequests">70+ contributions</a> that came from this and still keep coming in some other post. Back to the crossword. But first, a short break.</p>

<h4 id="ai-pit-stop">AI Pit Stop</h4>

<p>I asked one of the popular LLMs about the offense, and it told me to use <code class="language-plaintext highlighter-rouge">Float().nil?</code> (easy solution) or to disable the cop (ignore-the-problem solution).</p>

<p>I continued prompting for different ideas — taking care to not be explicit as to the desired approach — but it kept proposing more code solutions, each more complex than the last (e.g., to save result of <code class="language-plaintext highlighter-rouge">Float()</code> in a variable, then check if it equals <code class="language-plaintext highlighter-rouge">nil</code> on a separate line, thereby avoiding the cop). Only when I pointed out that <code class="language-plaintext highlighter-rouge">== nil</code> is correct, it conceded the offense is a false positive and the best course of action is to report the issue. I guided it there.</p>

<p>But what if you let AI guide? If you didn’t know any better, you might have stopped at <code class="language-plaintext highlighter-rouge">Float().nil?</code>. AI tools certainly make that easy,  when such solutions are only a keyboard shortcut away (and they come about even faster with agents). But amidst all that productivity, where did the time to stop and think go?</p>

<p>A fork in the road is a time to stop and think. Mine was a trivial one, yet it led me a long way. It could have just as easily led me nowhere. In fact, most forks <em>do</em> lead nowhere, but that just makes them more exciting when they don’t. But I never could have taken it had I not slowed down enough to see it, and not just be led the other way.</p>

<h2 id="applying-the-boy-scout-rule">Applying the Boy Scout Rule</h2>

<p>Ticking off cops from the report, I came upon <a href="https://docs.rubocop.org/rubocop/cops_lint.html#lintunusedmethodargument"><code class="language-plaintext highlighter-rouge">Lint/UnusedMethodArgument</code></a>. Here’s an illustrative offense:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="k">def</span> <span class="nf">payment_settings</span><span class="p">(</span><span class="n">country</span><span class="p">,</span> <span class="n">user</span><span class="p">)</span>
  <span class="no">Payments</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">settings</span><span class="p">(</span><span class="n">country</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>There were 1.1k offenses for the cop. RuboCop’s autocorrect will help you resolve all offenses quickly, by simply doing this:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="k">def</span> <span class="nf">payment_settings</span><span class="p">(</span><span class="n">country</span><span class="p">,</span> <span class="n">_user</span><span class="p">)</span>
  <span class="no">Payments</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">settings</span><span class="p">(</span><span class="n">country</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>The number of parameters is still the same, so existing method calls won’t break, and <code class="language-plaintext highlighter-rouge">_user</code> is clearly marked as unused. The solution is fast and correct. But is it the right one?</p>

<p>One of the goals of the initiative was to remove everything that’s redundant. Redundant code increases mental overhead. <code class="language-plaintext highlighter-rouge">_user</code> signals that we don’t have to pay attention to it, but it’s still there, and we have to provide it when calling the method. Plus, it got there because the method <em>used to</em> use the <code class="language-plaintext highlighter-rouge">user</code> argument, but then it was refactored to use only <code class="language-plaintext highlighter-rouge">country</code>. Meaning, this argument is a relic of the past. And past belongs in Git history.</p>

<p>So, I refactored all method callers to pass only <code class="language-plaintext highlighter-rouge">country</code> (and refactoring <em>their</em> callers as well if they didn’t need <code class="language-plaintext highlighter-rouge">user</code> anymore), then finally cleaned up the signature:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="k">def</span> <span class="nf">payment_settings</span><span class="p">(</span><span class="n">country</span><span class="p">)</span>
  <span class="no">Payments</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">settings</span><span class="p">(</span><span class="n">country</span><span class="p">)</span>
<span class="k">end</span></code></pre></figure>

<p>That looks right now, no?</p>

<p>This might not sound supportive, but I don’t see the <code class="language-plaintext highlighter-rouge">_user</code> solution as adding much benefit to an already legacy codebase. If anything, it’s only a bit less noise. If you want to commit to your own RuboCop initiative, ask yourself whether you just want to get the job done or whether you want to leave the project in a better state than the one it’s currently in.</p>

<h2 id="moving-along-steadily">Moving Along Steadily</h2>

<p>In Le Guin’s <a href="https://en.wikipedia.org/wiki/The_Left_Hand_of_Darkness">The Left Hand of Darkness</a>, there is a chapter about the perilous journey Genly Ai (who is a human) takes over the Gobrin ice sheet over 80 days. Proportionally speaking, it is much much longer than the novel’s other chapters. It is a gruelling trip, but also an important one and rather beautiful. If it were shorter, you wouldn’t feel it.</p>

<p>Don’t worry, I’m not mentally preparing you for a long section. While it took 300 days<sup><sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">2</a></sup></sup>, the bulk of it was repetitive enough to not be novel material.</p>

<p>Part of the reason it took that long is because I didn’t spend my whole days on it. I wouldn’t do that even if I could; I’d go mad just working on linter offenses all day long. I squeezed this work in between regular work that brought money, which turned out to be anywhere between 0 and 3 hours a day. Plus, other free time was invested in RuboCop contributions.</p>

<p>The only rule of thumb I adhered to was that PRs needed to be small enough to be reviewable. Grouping multiple cops in a single PR was okay as long as there were one or two offenses per cop.</p>

<p>One thing I learned is how to say No to scope creep. Sometimes, when fixing a cop’s offenses, opportunities to refactor methods and classes presented themselves. They seemed to go naturally with the fixes. Despite their allure, it is imperative <strong>not</strong> to do those refactors in the same PRs, no matter how small they are. Justifying them with “Just this once, it’s a small change” is a mental trap. Make a note and fix them later.<sup><sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">3</a></sup></sup> Otherwise, you’ll never get work done.</p>

<p>As a point of reference, I’ve enabled or fixed offenses for ~450 cops over the course of 360 PRs. If that sounds like a lot of work, that’s because it is, and not without reason.</p>

<p>RuboCop has two modes of autocorrection: safe and unsafe. Safe autocorrect <a href="https://docs.rubocop.org/rubocop/usage/autocorrect.html#safe-autocorrect">“indicates whether the autocorrect a cop does is safe (equivalent) by design”</a>. In theory, you could fix offenses for all safe cops in one PR and be done with it because the code will still produce equivalent results. In theory.</p>

<p>When I enabled <a href="https://docs.rubocop.org/rubocop/cops_style.html#stylesolenestedconditional"><code class="language-plaintext highlighter-rouge">Style/SoleNestedConditional</code></a>, which has safe autocorrect, it corrected this:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="k">unless</span> <span class="n">foo</span> <span class="o">&amp;&amp;</span> <span class="n">bar</span>
  <span class="mi">5</span> <span class="k">if</span> <span class="n">baz</span>
<span class="k">end</span></code></pre></figure>

<p>to this:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="k">if</span> <span class="o">!</span><span class="n">foo</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">bar</span> <span class="o">&amp;&amp;</span> <span class="n">baz</span>
  <span class="mi">5</span>
<span class="k">end</span></code></pre></figure>

<p>Fans of De Morgan’s laws will notice something amiss. <code class="language-plaintext highlighter-rouge">unless foo &amp;&amp; bar</code> is equivalent to <code class="language-plaintext highlighter-rouge">if !(foo &amp;&amp; bar)</code> (or <code class="language-plaintext highlighter-rouge">if !foo || !bar</code>), not <code class="language-plaintext highlighter-rouge">if !foo &amp;&amp; !bar</code>. But if you’re not careful enough, or trust the idea of safe autocorrection too much, this <a href="https://github.com/rubocop/rubocop/pull/14012">3-year-old bug</a> might evade you, even during a PR review.</p>

<p>RuboCop is a good tool, but it’s still made by humans. That’s why RuboCop’s changes should be treated just like anybody else’s. You would review a human’s changes, so you should review RuboCop’s too. Reviews usually aren’t effective when PRs are big. Hence, one small PR at a time. There are no shortcuts here.</p>

<p>Well, not without a cost anyway…</p>

<h2 id="a-cowboy-takes-the-shortcut">A Cowboy Takes the Shortcut</h2>

<p>A test suite with high code coverage can help with some cops.</p>

<p><a href="https://docs.rubocop.org/rubocop/cops_style.html#stylemutableconstant"><code class="language-plaintext highlighter-rouge">Style/MutableConstant</code></a> is a terrific cop, but it comes with a caveat: when it freezes a constant, all code paths which try to mutate it will raise an error:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="k">class</span> <span class="nc">Shop</span>
  <span class="no">DEFAULT_ITEMS</span> <span class="o">=</span> <span class="p">[</span><span class="ss">:bicycle</span><span class="p">,</span> <span class="ss">:camera</span><span class="p">].</span><span class="nf">freeze</span>

  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">sellable_items</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
    <span class="n">items</span> <span class="o">=</span> <span class="no">DEFAULT_ITEMS</span>

    <span class="c1"># raises `FrozenError` because `&lt;&lt;` mutates arrays</span>
    <span class="n">items</span> <span class="o">&lt;&lt;</span> <span class="ss">:fridge</span> <span class="k">if</span> <span class="n">user</span><span class="p">.</span><span class="nf">fridge_supported?</span>

    <span class="n">items</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>When <code class="language-plaintext highlighter-rouge">.freeze</code> is added to <code class="language-plaintext highlighter-rouge">DEFAULT_ITEMS</code> array, the only way you can be sure this code will still work is if <code class="language-plaintext highlighter-rouge">Shop#sellable_items</code> has sufficient code coverage.</p>

<p>The situation I found myself in when I wanted to enable the cop was this: code coverage was low (&lt; 20%), and there were more than 5200 constants that needed freezing. It’s not an ideal situation.</p>

<p>When I saw the offense report, I couldn’t help but be overwhelmed. Unlike other cops with many offenses (even cops without autocorrection support), the “right” way to fix offenses was tedious: go constant-by-constant and check each reference to see if it’s mutated at some point (which requires reading all relevant code and knowing which APIs have side effects).</p>

<p>Assuming a constant is referenced an average of 2 times (most constants are referenced only once, but some are referenced 100+ times), and that each reference takes 10 seconds to read through (optimistic, yes), 10400 references would take at least ~29 hours to check. Even my tenacity has its limits.</p>

<p>I debated with myself how to resolve the challenge, but didn’t have a good solution in mind. When I tried freezing all constants in a dry-run PR, the CI failed with errors for the code that <em>was</em> covered by tests, but I assumed that was just the tip of the iceberg. And because of our staging environment setup, deploying the dry-run PR for QA testing would be feasible only for critical flows, which were already covered by unit tests.</p>

<p>Out of ideas, I decided to autocorrect the offenses and deploy to production, accepting the possibility of errors. I told myself that code which mutates constants is buggy anyway, so temporary errors aren’t really that much worse than “working” bugs. I announced in the initiative channel what I was about to do and what it entailed, and also that the work will be spread over a couple of days to soften the blow. We also have application monitoring, I could keep my eyes peeled for <code class="language-plaintext highlighter-rouge">FrozenError</code>.</p>

<p>I merged one PR a day over the course of a week. As expected, there were errors. Three, to be exact, or 0.06% of all frozen constants. And fortunately all of them in non-critical code paths. That’s the thing about icebergs, you never know how deep they run.</p>

<p>This was the only cop in the initiative I handled the cowboy way. I didn’t know any better then. I don’t recommend the approach because it’s not professional, but sometimes it’s a cost-effective solution. If you’ll take that approach, the least you can do is be upfront about it and to spread the impact.</p>

<h4 id="ai-pit-stop-2">AI Pit Stop 2</h4>

<p>As I was finishing up this section of the post, it occurred to me that I could have asked AI to give me ideas for the problem. This might sound inconsistent with my opinion on AI in the first pit stop, but it’s actually not.</p>

<p>I’m not dismissive of AI; it’s just a tool. A tool has its uses. Each use has its advantages and disadvantages. It takes time to be aware of them. Taking time is not being dismissive, but it might certainly look like it in this ever accelerating world.</p>

<p>Anyway, I wondered whether I could track constant mutations somehow, so I asked one of the reasoning models to give me a couple of ideas. The options it provided gave me an idea to use <a href="https://docs.ruby-lang.org/en/4.0/Module.html#method-i-const_added"><code class="language-plaintext highlighter-rouge">Module#const_added</code></a> and a custom Rack middleware to figure out <em>which</em> constants are mutated in a request by comparing the state before and after, which would be enough information for me to then track down the mutation in code. In the end I had a working solution which I could use in production. It comes with some performance and memory overhead, but for temporary instrumentation I think that’s acceptable.</p>

<p>Looking back, I wish I had spent a bit more time thinking before freezing all constants. I looked into official documentation, Stack Overflow questions, various forums and read through Ruby issue tracker, but still, I didn’t connect the dots. In such cases, I don’t think using AI is a bad idea to explore options one might have missed or hasn’t thought of.</p>

<h2 id="sharing-the-sheriff-badge">Sharing the Sheriff Badge</h2>

<p>One of the advantages of RuboCop I highlighted is the ability to write custom cops. This would allow us to enforce rules specific to our codebase, for many people who come and go on the project. I knew that some big players utilize this feature well (GitLab, for example, <a href="https://gitlab.com/gitlab-org/gitlab/-/tree/389db2cb5e7a33720d1f8333ea7d902f2d3a87c2/rubocop/cop">has over 200 custom cops</a> at the moment), but I didn’t have any ideas in mind for our monolith, even when I was already a couple of months into the initiative.</p>

<p>Turns out, other people would come up with ideas for cops themselves.</p>

<p>We use the <a href="https://github.com/typhoeus/typhoeus">Typhoeus</a> gem for HTTP requests to different external services. By default, the gem doesn’t enforce a timeout for the total request duration. You could set a global timeout as a fallback, but that won’t cut it when some services should allow max 5 seconds, and others even up to 1 minute. The problem we had was that many places in our codebase didn’t use a timeout, which was a big issue for app reliability.</p>

<p>So, one dev came up with a cop to scan usages of Typhoeus API and enforce setting a timeout. With it, we identified all places where timeouts were missing, fixed them, and also made sure all future devs will be made aware you don’t want to run HTTP requests forever.</p>

<p>A month later, a cop for DB migrations appeared. Then, one for freezing YAML objects on load, another for not setting an explicit autoincrement column in factories, and so on. As patterns and anti-patterns emerge on the project, I’m sure even more cops will be created in the future. And if they’re generic enough, we can even try upstreaming them to RuboCop.</p>

<p>Of course, not every pattern should be a cop. Custom cops make sense when you can reliably implement them (with little probability of false positives) and when they would catch offenses often enough (spending 3 hours writing a cop to catch 2 nitpick offenses in a year is probably best left to manual review). A cop is something you have to write, test, document and maintain, so the cost must be justified.</p>

<p>And when cost is involved, there’s one cardinal mistake you don’t want to make.</p>

<h2 id="staying-on-rois-good-side">Staying on ROI’s Good Side</h2>

<p>The days were going by. Cop after cop, department after department, the report was getting shorter and shorter. After Lint, I turned to FactoryBot, RSpec, Security, Naming, Rails, Performance, Style and Layout departments. I worked sometimes by department and sometimes by the number of offenses.</p>

<p>FactoryBot, I enabled almost all cops. RSpec, Security and Naming too. We needed about a half of Rails cops. A third of Performance cops, the ones which didn’t impact readability that much. A little more than a half of Style cops. The whole Layout department (with some customizations).</p>

<p>I disabled the <a href="https://docs.rubocop.org/rubocop/cops_metrics.html">Metrics department</a> altogether. Existing classes and methods were long and complex, and refactoring all that code was not in the initiative’s scope. But even if this were a new project, my experience with this department is that the rules are just too context-dependent to be generally useful.</p>

<p>To find out how others feel about Metrics, I did a little analysis of <a href="https://github.com/eliotsykes/real-world-rails">real-world-rails</a> for <code class="language-plaintext highlighter-rouge">rubocop:disable</code> directives. Here are statistics for disabled RuboCop-only cops grouped by department, at the time of writing:<sup><sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup></sup></p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"> 951 Metrics/
 503 Style/
 419 Lint/
 415 Layout/
 300 Security/
 206 Naming/
   5 Gemspec/
   4 Bundler/</code></pre></figure>

<p>Metrics cops are disabled almost twice as much as the next most disabled department. Out of the top 10 disabled cops, 6 come from Metrics:<sup><sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup></sup></p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"> 359 Layout/LineLength
 328 Metrics/AbcSize                  1
 243 Security/PublicSend
 201 Metrics/MethodLength             2
  93 Metrics/CyclomaticComplexity     3
  87 Metrics/BlockLength              4
  78 Metrics/PerceivedComplexity      5
  68 Metrics/ClassLength              6
  62 Lint/InterpolationCheck
  49 Naming/PredicateName</code></pre></figure>

<p>I don’t think Metrics cops are without merit (sometimes an offense will prompt me to refactor a method and I’ll end up with something cleaner), but the noise of directive comments is also something to consider. Plus, with rampant use of AI, the models tend to bend code over backwards to comply with the cops. Food for thought.</p>

<p>When there were around 50 cops left in the report, I started feeling saturated. It wasn’t that I got bored of the initiative, it was more that the remaining cops seemed to become <a href="https://en.wikipedia.org/wiki/Diminishing_returns">less and less useful</a>.</p>

<p>It began with a cop like <a href="https://docs.rubocop.org/rubocop/cops_style.html#styledoublenegation"><code class="language-plaintext highlighter-rouge">Style/DoubleNegation</code></a>. Like yes, I could change <code class="language-plaintext highlighter-rouge">!!something</code> into <code class="language-plaintext highlighter-rouge">!something.nil?</code>, sure. Or I could break up couple of chained multiline blocks to tick off <a href="https://docs.rubocop.org/rubocop/cops_style.html#stylemultilineblockchain"><code class="language-plaintext highlighter-rouge">Style/MultilineBlockChain</code></a> from the report, but why? Like Metrics, I don’t think these cops are without merit, but after I’ve fixed bugs, removed redundant code, simplified things, improved consistency, performance and syntax formatting, these remaining cops felt like playing with variations of the same code, one not that much better than the other.</p>

<p>So when I looked at the report and saw only such cops, I knew my job was done.</p>

<p>That is, from the technical side at least.</p>

<h2 id="dont-forget-about-others">Don’t Forget About Others</h2>

<p>Being intimate with RuboCop, it’s easy for me to overestimate how much the average developer is at ease with it, and easy to forget I’m not the only one using it. <em>Sigh.</em> The fun part is over, documentation drudgery begins.</p>

<p>I opted to write docs in the repository README. They explain:</p>
<ul>
  <li>what is RuboCop and what we are using it for (linting and syntax formatting),</li>
  <li>terminology (rule == cop, rule violation == offense, cop category == department),</li>
  <li>how to run RuboCop locally,</li>
  <li>how to autocorrect only layout offenses (<code class="language-plaintext highlighter-rouge">-x</code> flag),</li>
  <li>the difference between safe and unsafe autocorrection,</li>
  <li>our policy for disabling cops (which is: prefer not to, but if you must, explain the decision in the cop disable comment),</li>
  <li>editor integrations (e.g. Ruby LSP, Solargraph), and</li>
  <li>how to deal with CI failures.</li>
</ul>

<p>If you’re using RuboCop daily, these items might seem obvious and not worth documenting. Just remember that there was a point in time when you didn’t find them obvious either.</p>

<p>GitLab is also a <a href="https://docs.gitlab.com/development/rubocop_development_guide/">good example</a> in this regard. But whatever you choose to write, just make sure to adjust the language to the skill level of your team.</p>

<p>Burning issues are also a part of the initiative. Sometimes, CI would fail for random reasons. Other times, new code with offenses would be merged to <code class="language-plaintext highlighter-rouge">master</code> minutes after I enabled a cop. Less burning, but sometimes people would ask how to fix offenses for some cops and we’d discuss it. And then there was scheduled work, like periodically updating RuboCop and fixing new offenses.</p>

<p>On the topic of failing CI, I think it’s a good idea to check other people’s failing runs to see how adoption is progressing, and if there are areas for improvement. I typically spot check the runs from time to time. Also, since our CI has an API for fetching workflow logs, I wrote a script to extract some offense statistics.</p>

<p>Here are, for example, the top 20 failing cops for the past ~3k workflow runs:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text"> 486 Layout/MultilineMethodCallIndentation
 451 Layout/LineLength
 364 Style/FrozenStringLiteralComment
 297 Layout/TrailingWhitespace
 227 Layout/TrailingEmptyLines
 148 Style/TrailingCommaInArguments
 146 Style/RedundantConstantBase
  74 RSpec/VerifiedDoubles
  57 Style/TrailingCommaInHashLiteral
  56 Lint/Syntax
  53 Style/IfUnlessModifier
  48 Rails/Output
  48 Layout/FirstHashElementIndentation
  43 Layout/IndentationConsistency
  37 Rails/Blank</code></pre></figure>

<p>There were 3432 offenses in total for 151 cops, meaning the top 7 cops (~5%) account for ~62% of offenses. Notice how the top 7 cops are autocorrectable (one exception being <code class="language-plaintext highlighter-rouge">Layout/LineLength</code>, but only sometimes).</p>

<p>This suggests I could raise tooling awareness in the team. For example, I could tell people that some editors support trimming trailing whitespace on save (thereby avoiding <code class="language-plaintext highlighter-rouge">Layout/TrailingWhitespace</code> offenses). Or, I could come up with a solution to autocorrect some of these cops on file save, which people could then integrate with their editors (these cops’ safe autocorrect can generally be trusted).</p>

<p>One more use of this data is to check how many would-be issues and bugs have been prevented without PR reviews. While not shown in the top 20, RuboCop also caught Lint offenses similar to those when I just started the initiative. And while I’m sure most would be caught during a PR review, this is still saved time for everybody.</p>

<p>Speaking of time. I believe I’ve shared every relevant fact and figure, and probably one too many opinion. But now, almost 6 months after the final PR, I don’t think much about any of that. What I do think about, though, is one question:</p>

<h2 id="twenty-seven-thousand-green-dots">Twenty Seven Thousand Green Dots</h2>

<p>Given the opportunity, would I do it all again? That is, if I were to work on another huge legacy codebase, would I go through the same trouble of convincing management we need a linter and setting off months of menial work?</p>

<p>Yes, with a caveat.</p>

<p>When I began, I didn’t know that much. I didn’t know that what makes management accept an initiative is not a well-reasoned essay, but usually some guy called ROI who doesn’t care whether you like him or not. And I didn’t know how to start working, except by fixing one offense, and then another. I also didn’t know how to contribute to one of the most popular Ruby libraries out there, which, I previously assumed, was a done project that would have little use of me. And I for sure didn’t know how much time it would all take, though I did give an estimate (for some reason, I always have to).</p>

<p>But what I truly didn’t know is where that time would take <em>me</em>, and what I now know is where it could take somebody else.</p>

<p>My 300 days taught me many things. Another 300 would probably teach me a bit more (though honestly, I’d be on the lookout for RuboCop contributions the most), but I also know that diminishing returns are a thing, and that, even if they weren’t, interests change. Mine certainly have.</p>

<p>Therefore, my caveat is that I’d only do the whole thing again, but as a mentor.</p>

<p>There is, in fact, a sea of opportunity in menial, repetitive work; we just don’t tend to see it. The reason is simple.</p>

<p>We underestimate such work from the very start by assuming there’s nothing to learn from it. Because of that assumption, we don’t focus on the work fully. By not focusing fully, we miss the hidden opportunities. By missing the hidden opportunities, we end up exactly where we assumed we would.</p>

<div style="text-align: center">* * *</div>

<p>I’ve given glimpses into the monolith with figures and examples. One figure I didn’t give is that the 1+ million lines of code are split between 27 thousand files. There’s a quote <a href="https://quoteinvestigator.com/2013/02/20/moved-by-stats/">attributed to Bertrand Russell</a> that <em>“The mark of a civilized man is the capacity to read a column of numbers and weep.”</em> I’m not sure he had monoliths in mind.</p>

<p>As much as I’d like to, I can’t weep over a million lines of code. It’s a number that sounds great, but my senses just don’t react to it. A couple thousand, on the other hand, is a magnitude much familiar to my senses. After all, I’ve been looking at it for 300 days.</p>

<p>The chaos that magnitude brings is out of control of any living being. Good engineering can create lasting architecture, optimize API endpoints, reduce infrastructure costs, succinctly and transparently document decisions, write code that can change almost as fast as business decisions, but it can’t keep track of all lines of code forever. Tools can.</p>

<div style="text-align: center">* * *</div>

<p>When you run RuboCop, by default it will print a character per checked file to terminal output. An offense might result in a red <span style="color: red">E</span> (error) or a magenta <span style="color: magenta">W</span> (warning). For no offenses, you get a green dot.</p>

<p>I must have run RuboCop hundreds of times during the initiative. The slowness on a huge codebase bothered me enough <a href="/github-actions-rubocop-workflow/">to optimize it for CI</a>, which I then <a href="https://github.com/rails/rails/pull/54754">upstreamed to Rails</a>. Each run would first pause for a couple of seconds as RuboCop was warming up, then dutifully print out 27 thousand characters to inform me of the verdict. It was so annoying to see one <span style="color: red">E</span> in what was otherwise a sea of green dots.</p>

<p>But overall, it was good work and I liked it. I like what it brought, and that I didn’t know what that would be.</p>

<p><br /></p>

<p><span style="color: green; overflow-wrap: break-word;">
……………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………… Chop wood, carry water, fix offense
</span></p>

<p><br />
<br />
<br />
<br />
<br /></p>

<h4 id="footnotes">Footnotes</h4>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>If you find the statement cold, just know I’m a senior too. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>301 days to be precise, but developers are permitted off-by-one errors for marketing purposes. The first PR was on Oct 30, 2024, the last one on Aug 26, 2025. There were some PRs after that, but they were for periodic RuboCop updates as part of post-initiative support. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><code class="language-plaintext highlighter-rouge">Lint/UnusedMethodArgument</code> is an exception since the autocorrect would produce unfavorable code. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">

      <p>This is the command I ran in the terminal on the <code class="language-plaintext highlighter-rouge">real-world-rails</code> repo to get the disable directive statistics:<br />
<code class="language-plaintext highlighter-rouge">grep -ohrE "# rubocop:disable .+" real-world-rails/apps | grep -oE "(Bundler|Gemspec|Layout|Lint|Metrics|Naming|Security|Style)/" | sort | uniq -c | sort -nr</code> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p>The command is very similar to above command, and is left as an exercise for the reader. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[It's a story that begins with a pull request and ends with a Zen Buddhist saying.]]></summary></entry><entry><title type="html">Real-World Data RuboCop Offense Reports</title><link href="https://lovro-bikic.github.io/real-world-data-rubocop-offense-reports/" rel="alternate" type="text/html" title="Real-World Data RuboCop Offense Reports" /><published>2025-08-30T00:00:00+00:00</published><updated>2025-08-30T00:00:00+00:00</updated><id>https://lovro-bikic.github.io/real-world-data-rubocop-offense-reports</id><content type="html" xml:base="https://lovro-bikic.github.io/real-world-data-rubocop-offense-reports/"><![CDATA[<p>When contributing to RuboCop, I <a href="https://github.com/rubocop/rubocop/pull/14448">often</a> <a href="https://github.com/rubocop/rubocop-rails/pull/1501">include</a> <a href="https://github.com/rubocop/rubocop-rspec/pull/2097">real-world</a> <a href="https://github.com/rubocop/rubocop/pull/14288">offense</a> reports in pull request descriptions to show that cops I want to add or changes I plan to make will have a sizeable impact. I also do this because running anything on real-world data can surface potential implementation bugs.</p>

<p>In this post, I’ll show where I gather real-world data from and how I generate reports that can be shared with others. The reports include GitHub/GitLab links to specific lines of code, and they look like this:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># https://github.com/gitlabhq/gitlabhq/blob/ba8e6fd9f408a6a6e0d20a5fe0d378aa63247065/app/models/concerns/mentionable.rb#L200</span>
<span class="n">source</span><span class="p">.</span><span class="nf">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">key</span><span class="p">,</span> <span class="n">val</span><span class="o">|</span> <span class="n">mentionable</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="p">}</span>

<span class="c1"># https://github.com/gitlabhq/gitlabhq/blob/ba8e6fd9f408a6a6e0d20a5fe0d378aa63247065/config/initializers/fog_google_list_objects_match_glob_support.rb#L34</span>
<span class="o">**</span><span class="n">options</span><span class="p">.</span><span class="nf">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">k</span><span class="p">,</span> <span class="n">_</span><span class="o">|</span> <span class="n">allowed_opts</span><span class="p">.</span><span class="nf">include?</span> <span class="n">k</span> <span class="p">}</span>

<span class="c1"># https://github.com/gitlabhq/gitlabhq/blob/ba8e6fd9f408a6a6e0d20a5fe0d378aa63247065/lib/gitlab/ci/reports/test_suite_comparer.rb#L31-L33</span>
<span class="n">head_suite</span><span class="p">.</span><span class="nf">failed</span><span class="p">.</span><span class="nf">select</span> <span class="k">do</span> <span class="o">|</span><span class="n">key</span><span class="p">,</span> <span class="n">_</span><span class="o">|</span>
  <span class="n">base_suite</span><span class="p">.</span><span class="nf">failed</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
<span class="k">end</span><span class="p">.</span><span class="nf">values</span>

<span class="c1"># etc.</span></code></pre></figure>

<p>This is neat because you can see where the offenses have been caught (which, among other things, allows you to find false positives), they’re syntax-highlighted, and anybody can open the link to view the offense in context. The offense message is not displayed because in such reports it is less important; the code matters more.</p>

<p>First, I’ll talk about fetching real-world repositories on which to run RuboCop. Then, I’ll explain how to generate the reports with a custom RuboCop formatter.</p>

<h2 id="real-world-repositories">Real-world repositories</h2>

<p>When we talk about real-world Ruby data, it’s important to distinguish the type of data we need. For contributions to <code class="language-plaintext highlighter-rouge">rubocop-rails</code>, we’d like to have Rails repositories; likewise for <code class="language-plaintext highlighter-rouge">rubocop-rspec</code>, <code class="language-plaintext highlighter-rouge">rubocop</code> itself, and other similar gems.</p>

<p>I most commonly contribute to those three gems, so having relevant repos comes in handy. Fortunately, kind folks have compiled repos with real-world applications that can be quickly cloned.</p>

<p>These are:</p>
<ul>
  <li><a href="https://github.com/jeromedalbert/real-world-ruby-apps">real-world-ruby-apps</a> (for <code class="language-plaintext highlighter-rouge">rubocop</code>)</li>
  <li><a href="https://github.com/eliotsykes/real-world-rails">real-world-rails</a> (for <code class="language-plaintext highlighter-rouge">rubocop-rails</code>)</li>
  <li><a href="https://github.com/pirj/real-world-rspec">real-world-rspec</a> (for <code class="language-plaintext highlighter-rouge">rubocop-rspec</code>)</li>
</ul>

<p>These repos use <a href="https://git-scm.com/book/en/v2/Git-Tools-Submodules">submodules</a> to fetch other public repos. After following the installation steps from one of these repos’ READMEs, you’ll have lots of Ruby code checked out locally. This is the code on which we’ll run RuboCop.</p>

<p>The apps in these repos have their own <code class="language-plaintext highlighter-rouge">.rubocop.yml</code> files. While needed in the context of the project, we don’t want project-specific configuration to affect our RuboCop runs, so we’ll delete them:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash"><span class="c"># cd to the real-world folder, then:</span>
find <span class="nb">.</span> <span class="nt">-iname</span> <span class="s1">'.rubocop.yml'</span> <span class="se">\(</span> <span class="nt">-type</span> l <span class="nt">-o</span> <span class="nt">-type</span> f <span class="se">\)</span> <span class="nt">-delete</span></code></pre></figure>

<p>This will delete both files and symlinks named <code class="language-plaintext highlighter-rouge">.rubocop.yml</code> recursively.</p>

<p>Now we have the code ready locally. Next step is to create the RuboCop formatter.</p>

<h2 id="custom-formatter">Custom formatter</h2>

<p>To print the offenses with links, I wrote a custom RuboCop formatter which you can find in <a href="https://gist.github.com/lovro-bikic/d43d4eba38efe711d48f87a8575e5f8b">this GitHub gist</a>. It’s based on the Clang formatter, and it should replace the <a href="https://github.com/rubocop/rubocop/blob/master/lib/rubocop/formatter/clang_style_formatter.rb">clang file</a> in your local clone of RuboCop (you can also replace any other formatter if you prefer that).</p>

<p>The formatter uses Git to get the current revision of the file and the remote where it’s hosted, then constructs a URL from the rev and the line number, giving you a permalink. Also, unlike other formatters, if an offense spans multiple lines, it prints all lines instead of only the first one.</p>

<p>I keep the patch with the custom formatter <a href="https://git-scm.com/docs/git-stash">stashed</a> locally, and whenever I need it, I just apply the patch.</p>

<h2 id="running-rubocop">Running RuboCop</h2>

<p>When I run RuboCop on real-world data, I typically run it with one cop I’m adding or modifying.</p>

<p>Assuming the real-world repo is cloned in the same parent folder as <code class="language-plaintext highlighter-rouge">rubocop</code>, you can generate the report by running RuboCop from the <code class="language-plaintext highlighter-rouge">rubocop</code> folder, for example:</p>

<figure class="highlight"><pre><code class="language-bash" data-lang="bash">bundle <span class="nb">exec </span>rubocop ../real-world-ruby-apps/apps <span class="nt">--only</span> Style/HashSlice <span class="nt">-f</span> clang <span class="o">&gt;</span> report.rb</code></pre></figure>

<p>If all went well, <code class="language-plaintext highlighter-rouge">report.rb</code> should contain offenses for the cop (in this case, <code class="language-plaintext highlighter-rouge">Style/HashSlice</code>) for all applications from <code class="language-plaintext highlighter-rouge">real-world-ruby-apps</code>, formatted with public references. I save the report in a <code class="language-plaintext highlighter-rouge">.rb</code> file to get syntax highlighting in the IDE, which usually works, though highlighting may break depending on the offense code.</p>

<p>That’s it. I wish you good fortune in the contributions to come.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[How to run RuboCop on real-world data and generate offense reports to share with other people.]]></summary></entry><entry><title type="html">Consistent MySQL structure.sql Diffs for Rails</title><link href="https://lovro-bikic.github.io/consistent-mysql-structure-sql-diffs-rails/" rel="alternate" type="text/html" title="Consistent MySQL structure.sql Diffs for Rails" /><published>2025-07-29T00:00:00+00:00</published><updated>2025-07-29T00:00:00+00:00</updated><id>https://lovro-bikic.github.io/consistent-mysql-structure-sql-diffs-rails</id><content type="html" xml:base="https://lovro-bikic.github.io/consistent-mysql-structure-sql-diffs-rails/"><![CDATA[<p><a href="#recipe">Jump to recipe ↓</a></p>

<hr />

<p>Having worked most of my Rails career with PostgreSQL and <code class="language-plaintext highlighter-rouge">schema.rb</code> files, when I had to switch to MySQL and <code class="language-plaintext highlighter-rouge">structure.sql</code> for a new project, the proverb <em>“We don’t appreciate what we have until it’s gone”</em> grinned at me again.</p>

<p>It grinned because I hadn’t appreciated that, when running a migration, only the relevant part of the structure dump would change. Nor had I appreciated the <code class="language-plaintext highlighter-rouge">db:schema:dump</code> Rake task (and the now obsolete <code class="language-plaintext highlighter-rouge">db:structure:dump</code>) which would yield consistent results, unaffected by local data.</p>

<p>With MySQL, the story is different. For one, the structure dump contains the pesky <code class="language-plaintext highlighter-rouge">AUTO_INCREMENT</code> option for each table. The value of this option depends on the data at the time of the dump, and there’s no flag to exclude it from the output. If multiple people work on the same codebase, they can get different dumps, despite running the same migrations. This bug has been <a href="https://bugs.mysql.com/bug.php?id=20786">open since June 2006</a>, way back when I had just finished first grade (times which I now appreciate).<sup><sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup></sup></p>

<p>Then, not everybody uses the same MySQL client. For example, I prefer local development and use MySQL installed via Homebrew, which ships with <a href="https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html"><code class="language-plaintext highlighter-rouge">mysqldump</code></a>. Some colleagues work in containerized environments <a href="https://github.com/rails/rails/blob/40a3f2fedc12e70750e2b14ad096c135e7bb1df7/.devcontainer/Dockerfile#L9">that come with MariaDB</a> and, by extension, <a href="https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump"><code class="language-plaintext highlighter-rouge">mariadb-dump</code></a>. The output of <code class="language-plaintext highlighter-rouge">mysqldump</code> and <code class="language-plaintext highlighter-rouge">mariadb-dump</code> is similar, but not identical.</p>

<p>On the new project, the way to commit <code class="language-plaintext highlighter-rouge">structure.sql</code> was to fish out your schema changes from the diff soup, which looked a bit like this:</p>

<figure class="highlight"><pre><code class="language-patch" data-lang="patch"><span class="gd">--- a/db/structure.sql
</span><span class="gi">+++ b/db/structure.sql
</span><span class="p">@@ -1 +1 @@</span>
<span class="gd">-/*!999999\- enable the sandbox mode */
</span><span class="gi">+
</span><span class="p">@@ -5 +5 @@</span>
<span class="gd">-/*!40101 SET NAMES utf8mb4 */;
</span><span class="gi">+/*!50503 SET NAMES utf8mb4 */;
</span><span class="p">@@ -2075 +2075 @@</span> CREATE TABLE `a` (
<span class="gd">-/*!40101 SET character_set_client = utf8mb4 */;
</span><span class="gi">+/*!50503 SET character_set_client = utf8mb4 */;
</span><span class="p">@@ -2105 +2105 @@</span> CREATE TABLE `b` (
<span class="gd">-/*!40101 SET character_set_client = utf8mb4 */;
</span><span class="gi">+/*!50503 SET character_set_client = utf8mb4 */;
</span><span class="p">@@ -2116 +2116 @@</span> CREATE TABLE `c` (
<span class="gd">-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
</span><span class="gi">+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb3;
</span><span class="p">@@ -2132 +2132 @@</span> CREATE TABLE `d` (
<span class="gd">-/*!40101 SET character_set_client = utf8mb4 */;
</span><span class="gi">+/*!50503 SET character_set_client = utf8mb4 */;
</span><span class="p">@@ -2151 +2151 @@</span> CREATE TABLE `e` (
<span class="gd">-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
</span><span class="gi">+) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb3;
</span><span class="p">@@ -2160 +2160 @@</span> CREATE TABLE `f` (
<span class="gd">-/*!40101 SET character_set_client = utf8mb4 */;
</span><span class="gi">+/*!50503 SET character_set_client = utf8mb4 */;
</span><span class="p">@@ -2169 +2169 @@</span> CREATE TABLE `g` (
<span class="gd">-/*!40101 SET character_set_client = utf8mb4 */;
</span><span class="gi">+/*!50503 SET character_set_client = utf8mb4 */;
</span><span class="err">...</span></code></pre></figure>

<p>These diffs would repeat for all tables. More than once, they have caused accidentally committed lines and bad merge conflict resolutions. We kind of accepted the fact that <code class="language-plaintext highlighter-rouge">db/structure.sql</code> was to be found in unstaged changes at all times, even if your work didn’t touch the schema.</p>

<p>I don’t remember exactly which straw broke this camel’s back, but at some point I became determined to:</p>

<ol>
  <li>resolve all differences between <code class="language-plaintext highlighter-rouge">mysqldump</code>’s and <code class="language-plaintext highlighter-rouge">mariadb-dump</code>’s outputs, and</li>
  <li>make structure dumps idempotent (i.e., dump the same schema twice, get the same output),</li>
</ol>

<p>so that migrations update only the parts of the structure dump you intend to change and nothing else. The solution turned out to be normalizing the structure dump with some regexes. It’s a simple recipe, but it goes a long way.</p>

<h2 id="recipe">Recipe</h2>

<div id="recipe"></div>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># lib/tasks/db/schema/dump.rake</span>
<span class="no">Rake</span><span class="o">::</span><span class="no">Task</span><span class="p">[</span><span class="s1">'db:schema:dump'</span><span class="p">].</span><span class="nf">enhance</span> <span class="k">do</span>
  <span class="n">structure_sql_path</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'db/structure.sql'</span><span class="p">)</span>

  <span class="k">if</span> <span class="no">File</span><span class="p">.</span><span class="nf">exist?</span><span class="p">(</span><span class="n">structure_sql_path</span><span class="p">)</span>
    <span class="n">sql</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">read</span><span class="p">(</span><span class="n">structure_sql_path</span><span class="p">)</span>

    <span class="c1"># see https://dev.mysql.com/doc/refman/8.4/en/example-auto-increment.html</span>
    <span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="sr">/ AUTO_INCREMENT=[0-9]+/</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span>

    <span class="c1"># see https://mariadb.org/mariadb-dump-file-compatibility-change/</span>
    <span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="sr">/^.+enable the sandbox mode.+$\R/</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span>

    <span class="c1"># mariadb-dump prints the former, mysqldump prints the latter</span>
    <span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="s1">'/*!40101 SET NAMES utf8mb4 */;'</span><span class="p">,</span> <span class="s1">'/*!50503 SET NAMES utf8mb4 */;'</span><span class="p">)</span>
    <span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="s1">'/*!40101 SET character_set_client = utf8mb4 */;'</span><span class="p">,</span> <span class="s1">'/*!50503 SET character_set_client = utf8mb4 */;'</span><span class="p">)</span>

    <span class="no">File</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="n">structure_sql_path</span><span class="p">,</span> <span class="n">sql</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>With this script, when you invoke <code class="language-plaintext highlighter-rouge">bundle exec rails db:schema:dump</code>, Rails will first create a new <code class="language-plaintext highlighter-rouge">structure.sql</code> file, and then it will run the block.</p>

<p>The logic first checks if <code class="language-plaintext highlighter-rouge">db/structure.sql</code> exists. This is a precaution in case the dump fails for whatever reason, so that we don’t try to operate on a nonexistent file. If it does exist, we normalize it:</p>

<h3 id="removing-auto_increment">Removing AUTO_INCREMENT</h3>

<p>The first step is to remove all traces of the <code class="language-plaintext highlighter-rouge">AUTO_INCREMENT</code> option:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="sr">/ AUTO_INCREMENT=[0-9]+/</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span></code></pre></figure>

<p>which will in practice adjust all <code class="language-plaintext highlighter-rouge">CREATE TABLE</code> statements like so:</p>

<figure class="highlight"><pre><code class="language-patch" data-lang="patch"> CREATE TABLE `foo` (
   `id` int NOT NULL AUTO_INCREMENT,
   ...
   PRIMARY KEY (`id`)
<span class="gd">-) ENGINE=InnoDB AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb3;
</span><span class="gi">+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;</span></code></pre></figure>

<p><a href="https://dev.mysql.com/doc/refman/8.4/en/example-auto-increment.html"><code class="language-plaintext highlighter-rouge">AUTO_INCREMENT=value</code></a> tells the DB the next value for the <code class="language-plaintext highlighter-rouge">AUTO_INCREMENT</code> column. In the above example, <code class="language-plaintext highlighter-rouge">id</code> is that column, and the current value is <code class="language-plaintext highlighter-rouge">123</code>. When we insert a new row in table <code class="language-plaintext highlighter-rouge">foo</code> (and don’t set the <code class="language-plaintext highlighter-rouge">id</code> explicitly), its <code class="language-plaintext highlighter-rouge">id</code> will be <code class="language-plaintext highlighter-rouge">123</code>. After insertion, the <code class="language-plaintext highlighter-rouge">AUTO_INCREMENT</code> value will increment by one, becoming <code class="language-plaintext highlighter-rouge">AUTO_INCREMENT=124</code>.</p>

<p>As you can see, this is a data-dependent option, so it’s not that useful in our schema. Removing it will implicitly set the value to <code class="language-plaintext highlighter-rouge">1</code> when you load the schema (e.g., in your test environment), which is great.</p>

<h3 id="removing-mariadbs-sandbox-mode-comment">Removing MariaDB’s sandbox mode comment</h3>

<p>If you’ve worked with MariaDB before, you may have noticed this line at the top of your <code class="language-plaintext highlighter-rouge">structure.sql</code>:</p>

<figure class="highlight"><pre><code class="language-sql" data-lang="sql"><span class="cm">/*!999999\- enable the sandbox mode */</span></code></pre></figure>

<p>You won’t find this with MySQL. To resolve the difference, the script removes the line:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="sr">/^.+enable the sandbox mode.+$\R/</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span></code></pre></figure>

<p>The line we removed does just as it says: it enables the <a href="https://mariadb.com/docs/server/clients-and-utilities/mariadb-client/mariadb-command-line-client#sandbox">sandbox mode</a>, which disables <a href="https://mariadb.org/mariadb-dump-file-compatibility-change/">“any command that could do something on the shell”</a>. If you trust your dump file, the line won’t do much for you, so there’s no harm done in removing it.</p>

<h3 id="adjusting-conditional-executable-comments">Adjusting conditional executable comments</h3>

<p>The last step is to normalize executable comments:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="s1">'/*!40101 SET NAMES utf8mb4 */;'</span><span class="p">,</span> <span class="s1">'/*!50503 SET NAMES utf8mb4 */;'</span><span class="p">)</span>
<span class="n">sql</span><span class="p">.</span><span class="nf">gsub!</span><span class="p">(</span><span class="s1">'/*!40101 SET character_set_client = utf8mb4 */;'</span><span class="p">,</span> <span class="s1">'/*!50503 SET character_set_client = utf8mb4 */;'</span><span class="p">)</span></code></pre></figure>

<p>The exact replacements depend on your MySQL and MariaDB versions, but let me first briefly explain how these comments work.</p>

<p>For example, this comment:</p>

<figure class="highlight"><pre><code class="language-sql" data-lang="sql"><span class="cm">/*!40101 SET NAMES utf8mb4 */</span><span class="p">;</span></code></pre></figure>

<p>will execute the statement <code class="language-plaintext highlighter-rouge">SET NAMES utf8mb4</code> only if your MySQL/MariaDB server version is greater than or equal to <code class="language-plaintext highlighter-rouge">4.1.1</code> (version format after <code class="language-plaintext highlighter-rouge">!</code> is <code class="language-plaintext highlighter-rouge">Mmmrr</code>, which stands for <strong>M</strong>ajor version, <strong>m</strong>inor version, and <strong>r</strong>elease number). More on this <a href="https://dev.mysql.com/doc/refman/8.4/en/comments.html">here</a>.</p>

<p>Structure dumps include a bunch of these comments, but the problem is that MariaDB and MySQL differ in the version specified in the comment, and sometimes even in the statement that’s executed.</p>

<p>On my project, MySQL will print out <code class="language-plaintext highlighter-rouge">/*!50503 SET NAMES utf8mb4 */;</code>, while MariaDB prints out <code class="language-plaintext highlighter-rouge">/*!40101 SET NAMES utf8mb4 */;</code>. To resolve the difference, I’ve opted to adjust MariaDB’s comment so it matches the MySQL one. Instead of executing on version 4.1.1 and above, the statement will now execute on v5.5.3 and above.</p>

<p>This is acceptable for us because our versions of MySQL and MariaDB are both greater than 5.5.3. If you’re not sure which server version you’re running, you can check this through the Rails console:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">connection</span><span class="p">.</span><span class="nf">select_value</span><span class="p">(</span><span class="s1">'SELECT VERSION()'</span><span class="p">)</span></code></pre></figure>

<p>The <code class="language-plaintext highlighter-rouge">SET character_set_client = utf8mb4</code> statement is treated in a similar manner. Unfortunately, I can’t guarantee that the examples I’ve shown here will apply to your use case (since these comments change with MySQL/MariaDB versions), but what I’ve hopefully shown is how you can resolve the differences on your own.</p>

<h2 id="outro">Outro</h2>

<p>With the script in place, run <code class="language-plaintext highlighter-rouge">db:schema:dump</code> and commit the normalized structure dump. The next time a migration runs, <code class="language-plaintext highlighter-rouge">structure.sql</code> diff should include only the changes that actually need to be committed.</p>

<p>Make sure to also run <code class="language-plaintext highlighter-rouge">db:schema:dump</code> when you update MySQL or MariaDB to a newer version, to verify the script still works. If there’s an unwanted diff (most likely due to new conditional comments), a small adjustment to the script should hopefully remove it.</p>

<p>As for my project, I can’t even imagine going back to the old way of dealing with persistent diff noise. In this case at least, I appreciate the present more.</p>

<hr />

<h4 id="footnotes">Footnotes</h4>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>An individual decided to bake this bug report a cake, which you can witness in <a href="https://www.youtube.com/watch?v=oAiVsbXVP6k">this mildly concerning video</a>. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[A guide on how to remove noise from `structure.sql` diffs in Rails when working with MySQL.]]></summary></entry><entry><title type="html">Making Sure FactoryBot Builds Without Creating</title><link href="https://lovro-bikic.github.io/factory-bot-build-without-creating/" rel="alternate" type="text/html" title="Making Sure FactoryBot Builds Without Creating" /><published>2025-07-04T00:00:00+00:00</published><updated>2025-07-04T00:00:00+00:00</updated><id>https://lovro-bikic.github.io/factory-bot-build-without-creating</id><content type="html" xml:base="https://lovro-bikic.github.io/factory-bot-build-without-creating/"><![CDATA[<p><a href="#recipe">Jump to recipe ↓</a></p>

<hr />

<p><a href="https://github.com/thoughtbot/factory_bot">factory_bot</a> is a great gem for writing factories in Ruby tests. It supports <a href="https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#build-strategies">multiple strategies</a> to instantiate records, the most familiar of which are <code class="language-plaintext highlighter-rouge">create</code> and <code class="language-plaintext highlighter-rouge">build</code>.</p>

<p><code class="language-plaintext highlighter-rouge">build</code> simply returns an unpersisted record with set attributes and associations (also unpersisted, if configured properly), while <code class="language-plaintext highlighter-rouge">create</code> additionally saves it in the database.</p>

<p>Which strategy we use in which test depends on what the code does; if the code we’re testing interacts with the DB, we probably want to create records. But if we’re testing something DB-independent, like a model method that only uses attributes, then building is enough, and also preferable since it’s much faster than creating.</p>

<p>Unfortunately, on most projects I’ve worked on, I’ve encountered (and, admittedly, sometimes written) factories that would execute SQL queries even when you’d build them, which leaves the test suite in an unoptimized state.</p>

<p>It would happen for various reasons, e.g. an association defined using <code class="language-plaintext highlighter-rouge">create</code>:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="no">FactoryBot</span><span class="p">.</span><span class="nf">define</span> <span class="k">do</span>
  <span class="n">factory</span> <span class="ss">:pet</span> <span class="k">do</span>
    <span class="n">veterinarian</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:person</span><span class="p">)</span> <span class="p">}</span> <span class="c1"># FactoryBot.build(:pet) still INSERTs a person in DB</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>or a <code class="language-plaintext highlighter-rouge">build</code> callback that does something in the DB:</p>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="no">FactoryBot</span><span class="p">.</span><span class="nf">define</span> <span class="k">do</span>
  <span class="n">factory</span> <span class="ss">:pet</span> <span class="k">do</span>
    <span class="n">after</span><span class="p">(</span><span class="ss">:build</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">pet</span><span class="o">|</span>
      <span class="n">pet</span><span class="p">.</span><span class="nf">bite_the_vet!</span> <span class="c1"># presumably, updates last_bitten_at column on the poor vet</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>RuboCop can help somewhat here (e.g. <a href="https://docs.rubocop.org/rubocop-factory_bot/cops_factorybot.html#factorybotfactoryassociationwithstrategy"><code class="language-plaintext highlighter-rouge">FactoryBot/FactoryAssociationWithStrategy</code></a> cop will handle associations), but it can’t cover all bases.</p>

<p>I recently spent some time optimizing the test suite on a project with more than 250 factories and plenty of traits. However, checking each factory’s build behavior individually was tedious. To help myself find non-buildable offenders — and prevent them from being introduced in the future — I wrote a spec that uses the handy <a href="https://github.com/nepalez/rspec-sqlimit">rspec-sqlimit</a> gem to check that for me:</p>

<div id="recipe"></div>

<figure class="highlight"><pre><code class="language-ruby" data-lang="ruby"><span class="c1"># spec/factory_bot_spec.rb</span>
<span class="no">RSpec</span><span class="p">.</span><span class="nf">describe</span> <span class="no">FactoryBot</span> <span class="k">do</span>
  <span class="n">describe</span> <span class="s1">'.build'</span> <span class="k">do</span>
    <span class="n">described_class</span><span class="p">.</span><span class="nf">factories</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">factory</span><span class="o">|</span>
      <span class="n">context</span> <span class="s2">"with factory :</span><span class="si">#{</span><span class="n">factory</span><span class="p">.</span><span class="nf">name</span><span class="si">}</span><span class="s2">"</span> <span class="k">do</span>
        <span class="n">it</span> <span class="s2">"doesn't execute SQL queries"</span> <span class="k">do</span>
          <span class="n">expect</span> <span class="p">{</span> <span class="n">build</span><span class="p">(</span><span class="n">factory</span><span class="p">.</span><span class="nf">name</span><span class="p">)</span> <span class="p">}.</span><span class="nf">not_to</span> <span class="n">exceed_query_limit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
        <span class="k">end</span>

        <span class="n">factory</span><span class="p">.</span><span class="nf">defined_traits</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">trait</span><span class="o">|</span>
          <span class="n">context</span> <span class="s2">"with trait :</span><span class="si">#{</span><span class="n">trait</span><span class="p">.</span><span class="nf">name</span><span class="si">}</span><span class="s2">"</span> <span class="k">do</span>
            <span class="n">it</span> <span class="s2">"doesn't execute SQL queries"</span> <span class="k">do</span>
              <span class="n">expect</span> <span class="p">{</span> <span class="n">build</span><span class="p">(</span><span class="n">factory</span><span class="p">.</span><span class="nf">name</span><span class="p">,</span> <span class="n">trait</span><span class="p">.</span><span class="nf">name</span><span class="p">)</span> <span class="p">}.</span><span class="nf">not_to</span> <span class="n">exceed_query_limit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
            <span class="k">end</span>
          <span class="k">end</span>
        <span class="k">end</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span></code></pre></figure>

<p>If any built factory executes an SQL query, the test will fail, and the output from <code class="language-plaintext highlighter-rouge">rspec-sqlimit</code> will show you which queries ran, helping you pinpoint the cause.</p>

<p>Of course, if some queries are unavoidable even when building (maybe some <code class="language-plaintext highlighter-rouge">SELECT</code>s), then you can pass additional options to the <code class="language-plaintext highlighter-rouge">exceed_query_limit</code> matcher to exclude them.</p>

<p>That’s all, thanks for reading!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[An RSpec spec to verify `FactoryBot.build` doesn't interact with the DB.]]></summary></entry><entry><title type="html">Running RuboCop on GitHub Actions With Cache</title><link href="https://lovro-bikic.github.io/github-actions-rubocop-workflow/" rel="alternate" type="text/html" title="Running RuboCop on GitHub Actions With Cache" /><published>2025-03-13T00:00:00+00:00</published><updated>2025-03-13T00:00:00+00:00</updated><id>https://lovro-bikic.github.io/github-actions-rubocop-workflow</id><content type="html" xml:base="https://lovro-bikic.github.io/github-actions-rubocop-workflow/"><![CDATA[<p>Here’s how to set up a RuboCop workflow on GitHub Actions with caching for faster workflow runs.</p>

<p>This workflow has been battle-tested on a rather active Rails monolith where running RuboCop would take 8 minutes without cache. Fortunately, caching drops that time to ~40 seconds.</p>

<p>The workflow assumes you have a Gemfile that includes the <code class="language-plaintext highlighter-rouge">rubocop</code> gem.</p>

<p>Finished product first, followed by a detailed breakdown. This can be copy-pasted, but you might need to adjust it to fit your own needs.</p>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml"><span class="c1"># .github/workflows/rubocop.yml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">RuboCop</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">${{ github.workflow }}-${{ github.ref }}</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="no">true</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="c1"># Keep one, delete the other</span>
      <span class="pi">-</span> <span class="s">master</span>
      <span class="pi">-</span> <span class="s">main</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">rubocop</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">RUBOCOP_CACHE_ROOT</span><span class="pi">:</span> <span class="s">tmp/rubocop</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Git checkout</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Ruby</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="no">true</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Prepare RuboCop cache</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/cache@v4</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">DEPENDENCIES_HASH</span><span class="pi">:</span> <span class="s">${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">${{ env.RUBOCOP_CACHE_ROOT }}</span>
          <span class="na">key</span><span class="pi">:</span> <span class="s">rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch &amp;&amp; github.run_id || 'default' }}</span>
          <span class="na">restore-keys</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run RuboCop</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec rubocop --format github --format clang</span></code></pre></figure>

<h2 id="workflow-breakdown">Workflow breakdown</h2>

<p>There are a few things going on here; let’s start from the top.</p>

<h3 id="concurrency">Concurrency</h3>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml"><span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">${{ github.workflow }}-${{ github.ref }}</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="no">true</span></code></pre></figure>

<p><a href="https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#example-using-concurrency-and-the-default-behavior">Mechanism</a> to limit in-progress workflow runs to one per branch. In practice, if you push a commit to a branch while a workflow is already running for a previous commit, that run will be cancelled.</p>

<h3 id="workflow-triggers">Workflow triggers</h3>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml"><span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">master</span>
      <span class="pi">-</span> <span class="s">main</span></code></pre></figure>

<p>Defines events that trigger the workflow.</p>

<p><code class="language-plaintext highlighter-rouge">pull_request</code> will run RuboCop when a PR is opened or updated, for example.</p>

<p><code class="language-plaintext highlighter-rouge">push</code> will run RuboCop on pushes to the default branch (master/main). This is used to both verify the latest commit passes RuboCop and to update the cache (more on that below).</p>

<h3 id="job-setup">Job setup</h3>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml"><span class="na">jobs</span><span class="pi">:</span>
  <span class="na">rubocop</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">RUBOCOP_CACHE_ROOT</span><span class="pi">:</span> <span class="s">tmp/rubocop</span></code></pre></figure>

<p>Defines the job and its runner and timeout. Adjust the timeout if necessary, but keep it as low as possible in case a workflow run gets stuck so you don’t get billed for wasted minutes (<a href="https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes">default timeout is 6 hours</a>, which could cost you a couple $$ if something goes awry and you don’t notice it (speaking from experience)).</p>

<p>This part of the workflow also sets up the <a href="https://docs.rubocop.org/rubocop/usage/caching.html#cache-path"><code class="language-plaintext highlighter-rouge">RUBOCOP_CACHE_ROOT</code></a> environment variable to save RuboCop cache in the <code class="language-plaintext highlighter-rouge">tmp/rubocop</code> folder. This is the folder we’ll be saving in GitHub Actions cache later on.</p>

<h3 id="repo-setup">Repo setup</h3>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml">    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Git checkout</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Ruby</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="no">true</span></code></pre></figure>

<p><a href="https://github.com/actions/checkout">Clones the repo</a> and <a href="https://github.com/ruby/setup-ruby/">installs and caches gems</a>.</p>

<h3 id="rubocop-cache-preparation">RuboCop cache preparation</h3>

<p>This is the main dish. The following step uses the <a href="https://github.com/actions/cache">cache action</a>.</p>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml">      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Prepare RuboCop cache</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/cache@v4</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">DEPENDENCIES_HASH</span><span class="pi">:</span> <span class="s">${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">${{ env.RUBOCOP_CACHE_ROOT }}</span>
          <span class="na">key</span><span class="pi">:</span> <span class="s">rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch &amp;&amp; github.run_id || 'default' }}</span>
          <span class="na">restore-keys</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-</span></code></pre></figure>

<p>The path we’re caching is defined in the environment variable <code class="language-plaintext highlighter-rouge">RUBOCOP_CACHE_ROOT</code> that was set on job-level. Setting this folder explicitly ensures RuboCop cache is located in the same folder that GH Actions caches.</p>

<p>Cache is saved under the key:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">rubocop-cache-
${{ runner.os }}-
${{ env.DEPENDENCIES_HASH }}-
${{ github.ref_name == github.event.repository.default_branch &amp;&amp; github.run_id || 'default' }</code></pre></figure>

<p><code class="language-plaintext highlighter-rouge">${{ runner.os }}</code> is added as <a href="https://github.com/actions/cache?tab=readme-ov-file#example-cache-workflow">“standard” practice</a> to not use a cache if you switch to a runner on a different OS.</p>

<p><code class="language-plaintext highlighter-rouge">${{ env.DEPENDENCIES_HASH }}</code> is an environment variable defined as:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock')</code></pre></figure>

<p><a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#hashfiles">hashFiles</a> returns a hash of a given set of files and this particular set aims to follow RuboCop’s <a href="https://docs.rubocop.org/rubocop/usage/caching.html#cache-validity">cache validity rules</a>. An existing cache won’t be used in a later run if:</p>
<ul>
<li>Ruby version changes (assuming there's a <code>.ruby-version</code> file), or</li>
<li><a href="https://docs.rubocop.org/rubocop/configuration.html#config-file-locations">any</a> <code>.rubocop.yml</code> file changes, or</li>
<li><a href="https://docs.rubocop.org/rubocop/configuration.html#automatically-generated-configuration"><code>.rubocop_todo.yml</code></a> file changes, or</li>
<li>RuboCop version changes (technically, cache will be invalidated any time Gemfile.lock changes, but this is a pragmatic choice to not overcomplicate the setup).</li>
</ul>

<p>Finally, <code class="language-plaintext highlighter-rouge">${{ github.ref_name == github.event.repository.default_branch &amp;&amp; github.run_id || 'default' }</code> is added to ensure the default branch has <a href="https://github.com/actions/cache/blob/main/tips-and-workarounds.md#update-a-cache">up-to-date cache</a> (its cache key ends with <a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context">workflow run ID</a>, which changes for each commit). Other branches have a single cache (their cache key ends with <code class="language-plaintext highlighter-rouge">default</code>).</p>

<p>When <a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#matching-a-cache-key">restoring cache</a>, if there’s no exact key match, we try to restore the most recent cache that matches the prefix<br />
<code class="language-plaintext highlighter-rouge">rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-</code></p>

<h4 id="how-does-this-setup-function-in-practice">How does this setup function in practice?</h4>

<p>On all branches, if dependencies (runner OS or <code class="language-plaintext highlighter-rouge">DEPENDENCIES_HASH</code>) change in a new commit, cache is not restored and RuboCop has to scan the entire codebase from scratch. Consequently, these runs are the slowest ones. When they finish, their RuboCop cache is saved for later use.</p>

<p>On the default branch, each commit creates a new cache entry as means of “updating” the cache. Cache from a previous workflow run is restored if dependencies match, but it will always be saved under a new key. This is necessary because GHA cache is immutable so when there’s a cache hit you won’t be able to update its contents. If we don’t “update” the cache, it will become less and less useful over time as source files change (RuboCop invalidates a file’s cache if its contents change).</p>

<p>On branches other than the default, first workflow run will try to restore the most recent cache from <a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache">base/default branch</a>. The cache will be saved under a key ending in <code class="language-plaintext highlighter-rouge">default</code> when the workflow finishes. If dependencies don’t change in a later commit, this will be the single cache entry for that branch.</p>

<p>A branch’s <code class="language-plaintext highlighter-rouge">default</code> cache might not be so useful in a later commit if you change a bunch of files, but this setup works okay for most workflows (for example: first commit changes the most files, and later commits update only some files after a PR review, so most of the cache is still utilized).</p>

<h3 id="blast-off">Blast-off</h3>

<p>Finally, we run RuboCop:</p>

<figure class="highlight"><pre><code class="language-yaml" data-lang="yaml">      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run RuboCop</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec rubocop --format github --format clang</span></code></pre></figure>

<p><a href="https://docs.rubocop.org/rubocop/formatters.html#github-actions-formatter">GitHub Actions formatter</a> will add nice annotations in the UI if there are any offenses. <a href="https://docs.rubocop.org/rubocop/formatters.html#clang-style-formatter">Clang</a> formatter is useful if you want to inspect workflow logs.
<br />
<br />
Enjoy!
<br />
<br /></p>
<h4 id="ps-a-note-for-large-codebases">PS: A note for large codebases</h4>

<p>At the time of writing, RuboCop by default <a href="https://github.com/rubocop/rubocop/blob/b6678159b618a9274d56fd4a95310fa48f36666c/config/default.yml#L121">saves max 20k files in cache</a>. If your codebase is larger than that, you’ll want to update your configuration so it caches everything in the codebase to ensure optimal workflow times.</p>

<p>Here’s how to quickly check the number of files RuboCop will scan:</p>

<figure class="highlight"><pre><code class="language-text" data-lang="text">$ bundle exec rubocop --list-target-files | wc -l</code></pre></figure>

<p>Then, update <code class="language-plaintext highlighter-rouge">MaxFilesInCache</code> in your <code class="language-plaintext highlighter-rouge">.rubocop.yml</code> to a value greater than that.
<br />
<br /></p>

<hr />

<p><br /></p>
<h4 id="update-on-march-28-2025">Update on March 28, 2025</h4>
<p>This caching step has been <a href="https://github.com/rails/rails/pull/54754">added to GHA workflows created by new Rails apps</a>.</p>

<h4 id="update-on-august-29-2025">Update on August 29, 2025</h4>
<p>The article has been updated to include <code class="language-plaintext highlighter-rouge">.rubocop_todo.yml</code> in <code class="language-plaintext highlighter-rouge">DEPENDENCIES_HASH</code>. <a href="https://github.com/rails/rails/pull/55367">Thanks koic!</a></p>

<p><code class="language-plaintext highlighter-rouge">--format clang</code> has also been added to the last step for better workflow debugging.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Here’s how to set up a RuboCop workflow on GitHub Actions with caching for faster workflow runs.]]></summary></entry></feed>