Skip to content

Replace standardrb with rubocop-gusto and enable frozen string literals - #76

Merged
dduugg merged 4 commits into
mainfrom
replace-standard-with-rubocop-gusto
Sep 26, 2026
Merged

dduugg merged 4 commits into
mainfrom
replace-standard-with-rubocop-gusto

Conversation

@dduugg

@dduugg dduugg commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Why

The other rubyatscale gems (pack_stats, danger-migrations, and others) lint with rubocop-gusto. singed was the odd one out on standardrb. Switching gives these gems one shared lint config.

It also turns on Style/FrozenStringLiteralComment (EnforcedStyle: always_true). Standard disables that cop, and 13 of singed's Ruby files lacked the pragma. All 26 linted files now have it.

Tooling changes

  • Gemfile: removed standard and added rubocop-gusto (require: false).
  • Gemfile.lock: a targeted change.
    • Removed: standard, standard-custom, and standard-performance.
    • Added: rubocop-gusto 11.9.0 and its transitive dependencies (rubocop-rspec, rubocop-rake, rubocop-sorbet, sorbet-static/sorbet-runtime, spoom, rbi, rbs, code_teams, smart_todo, and a few small ones).
    • Every gem already in the lock kept its version.
    • PLATFORMS changed from ruby to aarch64-linux, arm64-darwin, x86_64-darwin, and x86_64-linux. sorbet-static, pulled in via rubocop-sorbet, has no pure-ruby build, so Bundler drops ruby. The new list covers GitHub's ubuntu runners and macOS dev machines.
    • BUNDLED WITH is unchanged.
  • .standard.yml: deleted.
  • .rubocop.yml: rewritten to follow the rubocop-gusto init template.
    • inherit_gem pulls in rubocop-gusto's config/default.yml and config/sidekiq.yml. singed ships Sidekiq middleware, which is what init detects.
    • plugins lists rubocop-gusto, rubocop-rspec, rubocop-performance, and rubocop-rake.
    • inherit_mode: merge covers Exclude/Include, so RuboCop's default excludes survive alongside vendor/**/*.
    • TargetRubyVersion stays at 3.3.
  • CI: .github/workflows/standardrb.yaml is replaced by .github/workflows/rubocop.yml.
    • The old workflow ran standardrb/standard-ruby-action@v1 on push only, with checks: write and contents: write.
    • The new one runs bundle exec rubocop on [push, pull_request] with contents: read, on Ruby 3.4.
    • It uses actions/checkout and ruby/setup-ruby pinned to SHAs, the same pins rubocop-gusto's own lint job uses, plus persist-credentials: false.
    • It passes actionlint and zizmor --offline.
  • build.yml, dependabot.yml, the README, and AGENTS.md needed no changes. AGENTS.md already documents bundle exec rubocop.

Offenses and how they were fixed

The first run found 105 offenses in 21 of 25 files. Adding the pragma then produced 12 Layout/EmptyLineAfterMagicComment offenses, which were fixed with safe -a. The final run inspects 26 files with 0 offenses, on Ruby 3.3, 3.4, and 4.0.

Cop Count Fix
RSpec/AnyInstance 25 Manual: specs stub .new to return a known instance, with no any_instance
Style/HashSyntax 15 Safe -a (shorthand label:)
Style/FrozenStringLiteralComment 13 Unsafe -A, reviewed (below)
Layout/SpaceInsideHashLiteralBraces 10 Safe -a
RSpec/ContextWording 7 Manual (with ... / when running on ...)
Style/PercentLiteralDelimiters 5 Safe -a
Style/Send 4 Manual: __send__, because os_open_command is private
Lint/RedundantCopDisableDirective 3 Safe -a: Rails/TimeZone isn't loaded and Metrics/AbcSize isn't enabled; also removed the orphaned rubocop:enable
RSpec/SpecFilePathFormat 3 Manual: git mv of two spec files, and describe Kernel
RSpec/RepeatedExampleGroupBody 2 Manual: the "default options" example now actually omits open:
Style/TrailingCommaInArrayLiteral / InHashLiteral 2 + 2 Safe -a
Gusto/NoMetaprogramming 1 Manual: ControllerExt uses ActiveSupport::Concern
Gusto/RedundantSpecHelperRequire 1 Unsafe -A, reviewed (below)
Style/HashEachMethods 1 Unsafe -A, reviewed (below)
Style/ModuleFunction 1 Inline disable (below)
Lint/UselessConstantScoping 1 Manual: moved TRUTHY_STRINGS above private (constants were never private, so it stays public)
One each 9 Safe -a: Layout/FirstMethodArgumentLineBreak, Layout/SpaceInLambdaLiteral, Lint/UnusedMethodArgument (queue became _queue), RSpec/MetadataStyle, RSpec/ScatteredLet, Style/BlockDelimiters, Style/CommentAnnotation, Style/Lambda, Style/StringLiteralsInInterpolation

Notable manual changes:

  • lib/singed/controller_ext.rb:3-7: self.included(base); base.extend(ClassMethods) became extend ActiveSupport::Concern, which requires active_support/concern. ControllerExt is only loaded from the Railtie, where ActiveSupport is always present.
    • Behavior is the same for ActionController::Base.
    • A new spec/singed/controller_ext_spec.rb covers the class method and the around_action wiring. A mutation test showed both examples fail if the Concern stops extending ClassMethods.
  • spec/singed/middleware_spec.rb was renamed to spec/singed/rack_middleware_spec.rb.
  • spec/singed/sidekiq_spec.rb was renamed to spec/singed/sidekiq/server_middleware_spec.rb.
  • The Sidekiq spec now stubs described_class.new and job_class.new in place of any_instance. A mutation test (always profile, never profile, drop the job) showed it still catches regressions: 5-6 failures per mutation.
  • The Rack spec stubs Singed::Speedscope.open in place of Flamegraph#open on any instance.

Cops configured or disabled

  1. The Sorbet department is disabled in .rubocop.yml.
    • singed has no Sorbet dependency, no sorbet/ config, and no typecheck step.
    • Sorbet/ValidSigil and Sorbet/StrictSigil would add # typed: sigils that nothing reads. # typed: strict would claim typing the gem doesn't have.
    • rubocop-gusto's own .rubocop.yml disables the department for the same reason.
    • config/sorbet.yml is not inherited. init only detected Sorbet because rubocop-sorbet put it in the lockfile.
  2. Inline # rubocop:disable Style/ModuleFunction on extend self at lib/singed.rb:10, with a comment explaining why.
    • extend self makes each plain-def method on Singed both a module method and a public instance method, and the gem has shipped that way since its first release.
    • The only rewrite that satisfies the cop, class << self or def self., would remove those instance methods for anyone who includes or extends Singed. That is a public API change for a published gem, which is out of scope here.

No other cops are configured or disabled, and there is no .rubocop_todo.yml. NewCops inherits rubocop-gusto's setting.

Unsafe autocorrects and frozen-string fixes

No frozen-string fixes were needed. No literal in lib/, exe/, bin/, the Rakefile, or spec/ is ever mutated, and the verification below backs this up.

Unsafe autocorrects, each applied one cop at a time and reviewed:

  • Style/FrozenStringLiteralComment added the pragma to 13 files, all at line 1 except exe/singed:2, where it follows the shebang:
    • exe/singed:2
    • lib/singed/backtrace_cleaner_ext.rb:1, lib/singed/cli.rb:1, lib/singed/controller_ext.rb:1, lib/singed/flamegraph.rb:1, lib/singed/kernel_ext.rb:1, lib/singed/rack_middleware.rb:1, lib/singed/railtie.rb:1, lib/singed/report.rb:1, lib/singed/rspec.rb:1
    • spec/singed/kernel_ext_spec.rb:1, spec/spec_helper.rb:1, spec/support/sidekiq.rb:1
    • To confirm the pragma takes effect, including after the shebang, every file was compiled with a probe literal, which compiled to putobject.
  • Gusto/RedundantSpecHelperRequire removed require "spec_helper" at spec/singed/sidekiq/server_middleware_spec.rb:3. .rspec already passes --require spec_helper, and the file passes run on its own.
  • Style/HashEachMethods changed list.each do |_addr, frame| to list.each_value do |frame| at lib/singed/report.rb:32. StackProf::Report#frames always returns a Hash (built with inject({}) in stackprof 0.2.13 through 0.2.28, which covers the gemspec's >= 0.2.13 range), and the key was already unused.

Three public strings are now frozen where they weren't on main: the ArgumentError messages from Flamegraph.new, Singed::CLI#opts.banner, and the elements of RackMiddleware::TRUTHY_STRINGS. Nothing in singed or its dependencies mutates them. Only outside code that appends to one of them in place would notice.

Verification

  • Specs: bundle exec rspec gives 42 examples, 0 failures on Ruby 3.3.11, 3.4.11, and 4.0.5. The 40-example baseline all still passes; the 2 new examples are the ControllerExt spec.
    • It also passes with RUBYOPT=--enable-frozen-string-literal, in 10 random orders, and with each file run alone.
    • Each commit passes on its own.
  • RuboCop: bundle exec rubocop reports 26 files, no offenses on all three Rubies. rubocop -L includes exe/singed, bin/*, the Rakefile, the Gemfile, and the gemspec.
  • Static sweep: a Prism pass listed every string literal in exe/, lib/, bin/, and the Rakefile; all are frozen. A grep of every mutating call (<<, bang methods, force_encoding, []=, read buffers, and so on) found no mutated literal. Literals handed to libraries were traced through each one:
    • OptionParser: banner and switch strings are never mutated.
    • Shellwords: shellescape dups before gsub!.
    • Also traced: Kernel#system, Pathname, and Rails initializer names.
    • Every string singed returns to callers is built by interpolation or join.
  • Dynamic checks:
    • On origin/main with Ruby 3.4 -W:deprecated, all paths below gave zero "literal string will be frozen" warnings. The warning mechanism was confirmed to fire.
    • An end-to-end exe/singed -o ... -r 50 -- ruby script.rb run, with PATH shims for sudo, rbspy, npx, and open, covered the password check, the rbspy record, chown, the JSON frame filter and rewrite, and the speedscope open. It was run with and without global frozen literals.
    • A library probe covered:
      • Kernel#flamegraph with open: true and false, and Singed.start/stop/save
      • Report#filter! through ActiveSupport::BacktraceCleaner
      • both Speedscope.open branches (npx and bundled)
      • RackMiddleware with X-Singed
      • the Sidekiq middleware with plain and ActiveJob payloads
    • In a real Rails 8.1 app, the Railtie set output_directory and the backtrace cleaner and installed the middleware. flamegraph :show and an X-Singed request each wrote a flamegraph.
    • Output matched origin/main, and nothing raised FrozenError.
  • Fresh Eyes: one local pre-push pass (fresh-eyes local --base origin/main) over the final diff: 26 files, 21 applicable checks. It found 0 blockers, 0 major, and 0 minor. Its only note (info) was that the title it inferred from the last commit subject undersold the scope, which this PR title addresses.

Pre-existing issues seen during verification, not introduced or fixed here, and the same on main:

  • exe/singed --bogus raises an uncaught OptionParser::InvalidOption, because opts.order sits outside the rescue.
  • Leaving out -r passes a bare --rate to rbspy.
  • Nested profiling (X-Singed on an action that also uses flamegraph :action) raises NoMethodError inside stackprof.
  • Without Bundler on Ruby 3.3 and 3.4, exe/singed raises NameError on Pathname, because cli.rb doesn't require pathname.

Lint with the shared rubocop-gusto configuration used across the other
rubyatscale gems instead of Standard.

- Gemfile: drop standard, add rubocop-gusto (require: false). The lockfile
  change is limited to removing standard's gems and adding rubocop-gusto's.
  sorbet-static (pulled in through rubocop-sorbet) has no pure-Ruby build,
  so the lockfile now lists concrete platforms instead of `ruby`.
- .rubocop.yml: inherit rubocop-gusto's default and sidekiq configs, target
  Ruby 3.3 (the gemspec minimum), keep RuboCop's default excludes, and
  disable the Sorbet department because this gem does not use Sorbet.
- Remove .standard.yml.
- Replace the StandardRB workflow with a RuboCop workflow that runs on push
  and pull_request with read-only permissions.
Safe autocorrections (rubocop -a): shorthand hash values, hash brace
spacing, trailing commas in multiline literals, percent-literal
delimiters, the `->` lambda literal, block delimiters, symbol-style RSpec
metadata, the unused Sidekiq middleware `queue` argument renamed to
`_queue`, and removal of disable directives for cops that are not loaded
(Rails/TimeZone) or not enabled (Metrics/AbcSize), along with the matching
orphaned `rubocop:enable`.

Unsafe autocorrections, applied one cop at a time and reviewed:

- Style/FrozenStringLiteralComment: add the magic comment to the 13 files
  that lacked it, including exe/singed, plus the blank line after it that
  Layout/EmptyLineAfterMagicComment requires.
- Gusto/RedundantSpecHelperRequire: drop `require "spec_helper"` from the
  Sidekiq spec; .rspec already passes `--require spec_helper`.
- Style/HashEachMethods: `list.each { |_addr, frame| }` becomes
  `list.each_value { |frame| }` in Singed::Report#filter!.
  StackProf::Report#frames always returns a Hash.
Library:

- Singed::ControllerExt: use ActiveSupport::Concern instead of a
  hand-written `self.included` hook (Gusto/NoMetaprogramming). Including the
  module still extends the host with ClassMethods, and
  Singed::ControllerExt::ClassMethods is unchanged. A new spec covers this.
- Singed::Sidekiq::ServerMiddleware: move TRUTHY_STRINGS above `private`,
  which never applied to it (Lint/UselessConstantScoping). The constant
  stays public, as it was before.
- Singed: keep `extend self` with an inline Style/ModuleFunction disable.
  `extend self` makes every Singed method a public instance method of the
  module as well as a module method. Moving them into `class << self`
  would remove those instance methods for anyone who includes or extends
  Singed.

Specs:

- Replace allow_/expect_any_instance_of (RSpec/AnyInstance). The Rack
  middleware spec stubs Singed::Speedscope.open. The Sidekiq spec hands
  Sidekiq and ActiveJob the middleware and job instances it asserts
  against. Breaking the middleware on purpose (always profiling, never
  profiling, or dropping the job) still fails the suite.
- Rename middleware_spec.rb and sidekiq_spec.rb to match the classes they
  describe (RSpec/SpecFilePathFormat), and drop the "extension" suffix
  from the Kernel spec's describe.
- Reword contexts to start with when/with (RSpec/ContextWording). The
  "default" Kernel#flamegraph context now omits `open:` so it actually
  tests the default, rather than repeating the `open: true` context
  (RSpec/RepeatedExampleGroupBody).
- Call the private Speedscope.os_open_command with __send__ (Style/Send).

Adding the frozen_string_literal comment everywhere caused no FrozenError.
A review of every mutating call in lib/, exe/, bin/ and the Rakefile found
no string literal that is mutated after it is created.
The "adds the flamegraph class method" example asserted respond_to(:flamegraph),
which every object satisfies because Kernel#flamegraph is public. It now checks
that the method is owned by ControllerExt::ClassMethods, so it fails if the
Concern stops extending ClassMethods.

The comment on `extend self` claimed every method is also an instance method;
`self.output_directory` is singleton-only, so say plain `def` methods instead.
@dduugg
dduugg requested a review from a team as a code owner September 26, 2026 17:29
@github-project-automation github-project-automation Bot moved this to Triage in Modularity Sep 26, 2026
@dduugg
dduugg merged commit 918c2c8 into main Sep 26, 2026
11 checks passed
@dduugg
dduugg deleted the replace-standard-with-rubocop-gusto branch September 26, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant