├── .rspec ├── lib ├── rubocop-gradual.rb └── rubocop │ ├── gradual │ ├── version.rb │ ├── results.rb │ ├── patch.rb │ ├── cli.rb │ ├── git.rb │ ├── formatters │ │ ├── base.rb │ │ └── autocorrect.rb │ ├── lock_file.rb │ ├── process │ │ ├── matcher.rb │ │ ├── diff.rb │ │ ├── printer.rb │ │ └── calculate_diff.rb │ ├── results │ │ ├── issue.rb │ │ └── file.rb │ ├── commands │ │ ├── autocorrect.rb │ │ └── base.rb │ ├── serializer.rb │ ├── configuration.rb │ ├── process.rb │ ├── rake_task.rb │ └── options.rb │ └── gradual.rb ├── spec ├── fixtures │ └── project │ │ ├── security_only_rubocop.yml │ │ ├── app │ │ ├── models │ │ │ └── book.rb │ │ └── controllers │ │ │ ├── application_controller.rb │ │ │ └── books_controller.rb │ │ ├── .rubocop.yml │ │ ├── autocorrected.lock │ │ ├── was_better.lock │ │ ├── full.lock │ │ ├── outdated.lock │ │ ├── outdated_file.lock │ │ └── was_worse.lock ├── spec_helper.rb └── rubocop │ ├── gradual │ └── rake_task_spec.rb │ └── gradual_spec.rb ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── Gemfile ├── exe └── rubocop-gradual ├── .github └── workflows │ ├── lint.yml │ └── main.yml ├── .rubocop.yml ├── LICENSE.txt ├── rubocop-gradual.gemspec ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/rubocop-gradual.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "rubocop/gradual" 4 | -------------------------------------------------------------------------------- /spec/fixtures/project/security_only_rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Security: 5 | Enabled: true 6 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RuboCop 4 | module Gradual 5 | VERSION = "0.3.6" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "rubocop/gradual" 6 | 7 | require "irb" 8 | IRB.start(__FILE__) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | Gemfile.lock 14 | -------------------------------------------------------------------------------- /spec/fixtures/project/app/models/book.rb: -------------------------------------------------------------------------------- 1 | class Book < ActiveRecord::Base 2 | def someMethod 3 | foo = baz 4 | Regexp.new(/\A

(.*)<\/p>\Z/m).match(full_document)[1] rescue full_document 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[rubocop spec] 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in rubocop-gradual.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "rspec", "~> 3.0" 11 | 12 | gem "rubocop-performance" 13 | gem "rubocop-rake" 14 | gem "rubocop-rspec" 15 | -------------------------------------------------------------------------------- /lib/rubocop/gradual.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubocop" 4 | 5 | require_relative "gradual/version" 6 | require_relative "gradual/cli" 7 | 8 | module RuboCop 9 | # RuboCop Gradual project namespace 10 | module Gradual 11 | class Error < StandardError; end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/project/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | 6 | # “Test encoding issues by using curly quotes” 7 | end 8 | -------------------------------------------------------------------------------- /exe/rubocop-gradual: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift("#{__dir__}/../lib") 5 | 6 | require "rubocop-gradual" 7 | require "benchmark" 8 | 9 | exit_status = 0 10 | cli = RuboCop::Gradual::CLI.new 11 | 12 | time = Benchmark.realtime { exit_status = cli.run } 13 | 14 | puts "Finished in #{time} seconds" if RuboCop::Gradual::Configuration.display_time? 15 | 16 | exit exit_status 17 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/results.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "parallel" 4 | 5 | require_relative "results/file" 6 | 7 | module RuboCop 8 | module Gradual 9 | # Results is a collection of FileResults. 10 | class Results 11 | attr_reader :files 12 | 13 | def initialize(files:) 14 | @files = Parallel.map(files) { |file| File.new(**file) }.sort 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | name: RuboCop Linter 14 | env: 15 | BUNDLE_JOBS: 4 16 | BUNDLE_RETRY: 3 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: "3.1" 23 | bundler-cache: true 24 | - name: Run RuboCop 25 | run: bundle exec rubocop 26 | -------------------------------------------------------------------------------- /spec/fixtures/project/.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/EmptyLinesAroundAccessModifier: 5 | Enabled: true 6 | Layout/IndentationConsistency: 7 | Enabled: true 8 | Layout/IndentationWidth: 9 | Enabled: true 10 | Layout/LineLength: 11 | Enabled: true 12 | Lint/UselessAssignment: 13 | Enabled: true 14 | Naming/MethodName: 15 | Enabled: true 16 | Style/Documentation: 17 | Enabled: true 18 | Style/EmptyMethod: 19 | Enabled: true 20 | Style/FrozenStringLiteralComment: 21 | Enabled: true 22 | Style/RegexpLiteral: 23 | Enabled: true 24 | Style/RescueModifier: 25 | Enabled: true 26 | Style/SymbolArray: 27 | Enabled: true 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tmpdir" 4 | 5 | require "rubocop/gradual" 6 | 7 | RSpec::Matchers.define_negated_matcher :not_include, :include 8 | 9 | RSpec.configure do |config| 10 | # Enable flags like --only-failures and --next-failure 11 | config.example_status_persistence_file_path = ".rspec_status" 12 | 13 | # Disable RSpec exposing methods globally on `Module` and `main` 14 | config.disable_monkey_patching! 15 | 16 | config.expect_with :rspec do |c| 17 | c.syntax = :expect 18 | end 19 | 20 | config.order = :random 21 | Kernel.srand config.seed 22 | 23 | config.after do 24 | RuboCop::Gradual::Configuration.apply 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | main: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | env: 15 | BUNDLE_JOBS: 4 16 | BUNDLE_RETRY: 3 17 | strategy: 18 | matrix: 19 | ruby: 20 | - "3.2" 21 | - "3.1" 22 | - "3.0" 23 | - "2.7" 24 | - "2.6" 25 | - "jruby" 26 | - "truffleruby" 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby }} 33 | bundler-cache: true 34 | - name: Run RSpec 35 | run: bundle exec rspec 36 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rspec 4 | - rubocop-rake 5 | 6 | AllCops: 7 | TargetRubyVersion: 2.6 8 | SuggestExtensions: false 9 | NewCops: enable 10 | Exclude: 11 | - 'spec/fixtures/**/*' 12 | - 'vendor/**/*' 13 | 14 | Style/StringLiterals: 15 | Enabled: true 16 | EnforcedStyle: double_quotes 17 | 18 | Style/StringLiteralsInInterpolation: 19 | Enabled: true 20 | EnforcedStyle: double_quotes 21 | 22 | Layout/LineLength: 23 | Max: 120 24 | 25 | Metrics/BlockLength: 26 | Exclude: 27 | - 'spec/**/*.rb' 28 | 29 | Naming/FileName: 30 | Exclude: 31 | - 'lib/rubocop-gradual.rb' 32 | 33 | RSpec/ExpectOutput: 34 | Enabled: false 35 | 36 | RSpec/MultipleExpectations: 37 | Enabled: false 38 | 39 | RSpec/NestedGroups: 40 | Max: 4 41 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubocop-gradual" 4 | 5 | module RuboCop 6 | module Gradual 7 | # Patching RuboCop::CLI to enable require mode. 8 | module Patch 9 | def run_command(name) 10 | return super if name != :execute_runner || (ARGV & %w[--stdin -s]).any? 11 | 12 | RuboCop::Gradual::CLI.new.run(patched_argv) 13 | end 14 | 15 | private 16 | 17 | def patched_argv 18 | return ARGV if ARGV[0] != "gradual" 19 | 20 | case ARGV[1] 21 | when "force_update" 22 | ARGV[2..] + ["--force-update"] 23 | when "check" 24 | ARGV[2..] + ["--check"] 25 | else 26 | raise ArgumentError, "Unknown gradual command #{ARGV[1]}" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | 33 | RuboCop::CLI.prepend(RuboCop::Gradual::Patch) if ENV["NO_GRADUAL"] != "1" 34 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "configuration" 4 | require_relative "options" 5 | 6 | module RuboCop 7 | module Gradual 8 | # CLI is a wrapper around RuboCop::CLI. 9 | class CLI 10 | def run(argv = ARGV) 11 | Configuration.apply(*Options.new.parse(argv)) 12 | puts "Gradual mode: #{Configuration.mode}" if Configuration.debug? 13 | cmd = load_command(Configuration.command) 14 | return list_target_files(cmd) if Configuration.rubocop_options[:list_target_files] 15 | 16 | cmd.call.to_i 17 | end 18 | 19 | private 20 | 21 | def list_target_files(cmd) 22 | cmd.lint_paths.each { |path| puts PathUtil.relative_path(path) } 23 | 1 24 | end 25 | 26 | def load_command(command) 27 | require_relative "commands/#{command}" 28 | ::RuboCop::Gradual::Commands.const_get(command.to_s.capitalize).new 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/git.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rbconfig" 4 | 5 | module RuboCop 6 | module Gradual 7 | # Git class handles git commands. 8 | module Git 9 | class << self 10 | def paths_by(commit) 11 | git_installed! 12 | 13 | case commit 14 | when :unstaged 15 | `git ls-files --others --exclude-standard -m`.split("\n") 16 | when :staged 17 | `git diff --cached --name-only --diff-filter=d`.split("\n") # excludes deleted files 18 | else 19 | `git diff --name-only #{commit}`.split("\n") 20 | end 21 | end 22 | 23 | private 24 | 25 | def git_installed! 26 | void = /msdos|mswin|djgpp|mingw/.match?(RbConfig::CONFIG["host_os"]) ? "NUL" : "/dev/null" 27 | git_found = `git --version >>#{void} 2>&1` 28 | 29 | raise Error, "Git is not found, please install it first." unless git_found 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/formatters/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | 5 | module RuboCop 6 | module Gradual 7 | module Formatters 8 | # Base is a RuboCop formatter class that collects RuboCop results and 9 | # writes them to Configuration.rubocop_results. 10 | class Base < RuboCop::Formatter::BaseFormatter 11 | include PathUtil 12 | 13 | def file_finished(file, offenses) 14 | print "." 15 | return if offenses.empty? 16 | 17 | Configuration.rubocop_results << { 18 | path: smart_path(file), 19 | issues: offenses.reject(&:corrected?).map { |o| issue_offense(o) } 20 | } 21 | end 22 | 23 | private 24 | 25 | def issue_offense(offense) 26 | { 27 | line: offense.line, 28 | column: offense.real_column, 29 | length: offense.location.length, 30 | message: offense.message 31 | } 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/formatters/autocorrect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | 5 | module RuboCop 6 | module Gradual 7 | module Formatters 8 | # Formatter is a RuboCop formatter class that collects RuboCop results and 9 | # calls the Gradual::Process class at the end to process them. 10 | class Autocorrect < RuboCop::Formatter::BaseFormatter 11 | include PathUtil 12 | 13 | def initialize(_output, options = {}) 14 | super 15 | @corrected_files = 0 16 | end 17 | 18 | def started(target_files) 19 | puts "Inspecting #{target_files.size} file(s) for autocorrection..." 20 | end 21 | 22 | def file_finished(_file, offenses) 23 | print "." 24 | return if offenses.empty? 25 | 26 | @corrected_files += 1 if offenses.any?(&:corrected?) 27 | end 28 | 29 | def finished(_inspected_files) 30 | puts "\nFixed #{@corrected_files} file(s).\n" 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/lock_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "diffy" 4 | 5 | require_relative "serializer" 6 | 7 | module RuboCop 8 | module Gradual 9 | # LockFile class handles reading and writing of lock file. 10 | class LockFile 11 | attr_reader :path 12 | 13 | def initialize(path) 14 | @path = path 15 | end 16 | 17 | def read_results 18 | return unless File.exist?(path) 19 | 20 | Serializer.deserialize(content) 21 | end 22 | 23 | def delete 24 | return unless File.exist?(path) 25 | 26 | File.delete(path) 27 | end 28 | 29 | def write_results(results) 30 | File.write(path, Serializer.serialize(results), encoding: Encoding::UTF_8) 31 | end 32 | 33 | def diff(new_results) 34 | Diffy::Diff.new(Serializer.serialize(new_results), content, context: 0) 35 | end 36 | 37 | private 38 | 39 | def content 40 | @content ||= File.exist?(path) ? File.read(path, encoding: Encoding::UTF_8) : "" 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/process/matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RuboCop 4 | module Gradual 5 | class Process 6 | # Matcher matches two files arrays and returns an object with matched map. 7 | class Matcher 8 | include Enumerable 9 | 10 | attr_reader :unmatched_keys, :unmatched_values, :matched 11 | 12 | def initialize(keys, values, matched) 13 | @unmatched_keys = keys - matched.keys 14 | @unmatched_values = values - matched.values 15 | @matched = matched 16 | end 17 | 18 | def each(&block) 19 | @matched.each(&block) 20 | end 21 | 22 | class << self 23 | def new(keys, values, property) 24 | matched = keys.each_with_object({}) do |key, result| 25 | match_value = values.find do |value| 26 | key.public_send(property) == value.public_send(property) 27 | end 28 | result[key] = match_value if match_value 29 | end 30 | 31 | super(keys, values, matched) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/fixtures/project/autocorrected.lock: -------------------------------------------------------------------------------- 1 | { 2 | "app/controllers/application_controller.rb:1968181156": [ 3 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 4 | [1, 1, 27, "Style/Documentation: Missing top-level documentation comment for `class ApplicationController`.", 162509677] 5 | ], 6 | "app/controllers/books_controller.rb:4260410131": [ 7 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 8 | [1, 1, 21, "Style/Documentation: Missing top-level documentation comment for `class BooksController`.", 2020578605], 9 | [64, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604] 10 | ], 11 | "app/models/book.rb:2300652339": [ 12 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 13 | [1, 1, 10, "Style/Documentation: Missing top-level documentation comment for `class Book`.", 3515554338], 14 | [2, 7, 10, "Naming/MethodName: Use snake_case for method names.", 3866179310], 15 | [3, 5, 3, "Lint/UselessAssignment: Useless assignment to variable - `foo`.", 193410979] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Svyatoslav Kryukov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/results/issue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RuboCop 4 | module Gradual 5 | class Results 6 | # IssueResults is a representation of an issue in a Gradual results. 7 | class Issue 8 | attr_reader :line, :column, :length, :message, :code_hash 9 | 10 | def initialize(line:, column:, length:, message:, hash:) 11 | @line = line 12 | @column = column 13 | @length = length 14 | @message = message 15 | @code_hash = hash 16 | end 17 | 18 | def <=>(other) 19 | [line, column, length, message] <=> [other.line, other.column, other.length, other.message] 20 | end 21 | 22 | def to_s 23 | "[#{[line, column, length, JSON.dump(message), code_hash].join(", ")}]" 24 | end 25 | 26 | def ==(other) 27 | line == other.line && column == other.column && length == other.length && code_hash == other.code_hash 28 | end 29 | 30 | def distance(other) 31 | [(line - other.line).abs, (column - other.column).abs] 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /rubocop-gradual.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/rubocop/gradual/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "rubocop-gradual" 7 | spec.version = RuboCop::Gradual::VERSION 8 | spec.authors = ["Svyatoslav Kryukov"] 9 | spec.email = ["me@skryukov.dev"] 10 | 11 | spec.summary = "Gradual RuboCop plugin" 12 | spec.description = "Gradually improve your code with RuboCop." 13 | spec.homepage = "https://github.com/skryukov/rubocop-gradual" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 2.6.0" 16 | 17 | spec.metadata = { 18 | "bug_tracker_uri" => "#{spec.homepage}/issues", 19 | "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md", 20 | "documentation_uri" => "#{spec.homepage}/blob/main/README.md", 21 | "homepage_uri" => spec.homepage, 22 | "source_code_uri" => spec.homepage, 23 | "rubygems_mfa_required" => "true" 24 | } 25 | 26 | spec.bindir = "exe" 27 | spec.executables = ["rubocop-gradual"] 28 | spec.files = Dir.glob("lib/**/*") + %w[exe/rubocop-gradual README.md LICENSE.txt CHANGELOG.md] 29 | spec.require_paths = ["lib"] 30 | 31 | spec.add_dependency "diff-lcs", ">= 1.2.0", "< 2.0" 32 | spec.add_dependency "diffy", "~> 3.0" 33 | spec.add_dependency "parallel", "~> 1.10" 34 | spec.add_dependency "rainbow", ">= 2.2.2", "< 4.0" 35 | spec.add_dependency "rubocop", "~> 1.0" 36 | end 37 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/commands/autocorrect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "base" 4 | require_relative "../formatters/autocorrect" 5 | 6 | module RuboCop 7 | module Gradual 8 | module Commands 9 | # Autocorrect command runs RuboCop autocorrect before running the base command. 10 | class Autocorrect 11 | def call 12 | runner = RuboCop::CLI::Command::ExecuteRunner.new( 13 | RuboCop::CLI::Environment.new( 14 | Configuration.rubocop_options.merge(formatters: [[Formatters::Autocorrect, nil]]), 15 | Configuration.rubocop_config_store, 16 | lint_paths 17 | ) 18 | ) 19 | runner.run 20 | Base.new.call 21 | end 22 | 23 | def lint_paths 24 | return Configuration.target_file_paths if Configuration.lint_paths.any? 25 | 26 | changed_or_untracked_files.map(&:path) 27 | end 28 | 29 | private 30 | 31 | def changed_or_untracked_files 32 | tracked_files = LockFile.new(Configuration.path).read_results&.files || [] 33 | 34 | target_files.reject do |file| 35 | tracked_files.any? { |r| r.path == file.path && r.file_hash == file.file_hash } 36 | end 37 | end 38 | 39 | def target_files 40 | Parallel.map(Configuration.target_file_paths) do |path| 41 | Results::File.new(path: path, issues: []) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RuboCop 4 | module Gradual 5 | # Serializer is a module used to serialize and deserialize RuboCop results to the lock file. 6 | module Serializer 7 | class << self 8 | def serialize(results) 9 | "#{serialize_files(results.files)}\n" 10 | end 11 | 12 | def deserialize(data) 13 | files = JSON.parse(data).map do |key, value| 14 | path, hash = key.split(":") 15 | raise Error, "Wrong format of the lock file: `#{key}` must include hash" if hash.nil? || hash.empty? 16 | 17 | issues = value.map do |line, column, length, message, issue_hash| 18 | { line: line, column: column, length: length, message: message, hash: issue_hash } 19 | end 20 | { path: path, hash: hash.to_i, issues: issues } 21 | end 22 | Results.new(files: files) 23 | end 24 | 25 | private 26 | 27 | def serialize_files(files) 28 | data = files.map do |file| 29 | key = "#{file.path}:#{file.file_hash}" 30 | issues = serialize_issues(file.issues) 31 | [key, issues] 32 | end 33 | key_values_to_json(data) 34 | end 35 | 36 | def serialize_issues(issues) 37 | "[\n#{indent(issues.join(",\n"))}\n]" 38 | end 39 | 40 | def key_values_to_json(arr) 41 | arr.map { |key, value| indent(%("#{key}": #{value})) } 42 | .join(",\n") 43 | .then { |data| "{\n#{data}\n}" } 44 | end 45 | 46 | def indent(str, indent_str = " ") 47 | str.lines 48 | .map { |line| indent_str + line } 49 | .join 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/commands/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "benchmark" 4 | 5 | require_relative "../formatters/base" 6 | require_relative "../process" 7 | 8 | module RuboCop 9 | module Gradual 10 | module Commands 11 | # Base command runs RuboCop, and processes the results with Gradual. 12 | class Base 13 | def call 14 | exit_code = 0 15 | run_rubocop 16 | write_stats_message 17 | time = Benchmark.realtime { exit_code = Process.new(Configuration.rubocop_results).call } 18 | puts "Finished Gradual processing in #{time} seconds" if Configuration.display_time? 19 | 20 | exit_code 21 | rescue RuboCop::Error => e 22 | warn "\nRuboCop Error: #{e.message}" 23 | 1 24 | end 25 | 26 | def lint_paths 27 | Configuration.target_file_paths 28 | end 29 | 30 | private 31 | 32 | def run_rubocop 33 | rubocop_runner = RuboCop::CLI::Command::ExecuteRunner.new( 34 | RuboCop::CLI::Environment.new( 35 | rubocop_options, 36 | Configuration.rubocop_config_store, 37 | lint_paths 38 | ) 39 | ) 40 | rubocop_runner.run 41 | end 42 | 43 | def rubocop_options 44 | Configuration.rubocop_options 45 | .slice(:config, :debug, :display_time) 46 | .merge(formatters: [[Formatters::Base, nil]]) 47 | end 48 | 49 | def write_stats_message 50 | issues_count = Configuration.rubocop_results.sum { |f| f[:issues].size } 51 | puts "\nFound #{Configuration.rubocop_results.size} files with #{issues_count} issue(s)." 52 | puts "Processing results..." 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RuboCop 4 | module Gradual 5 | # Configuration class stores Gradual and Rubocop options. 6 | module Configuration 7 | class << self 8 | attr_reader :options, :rubocop_options, :rubocop_results, :lint_paths, :target_file_paths 9 | 10 | def apply(options = {}, rubocop_options = {}, lint_paths = []) 11 | @options = options 12 | @rubocop_options = rubocop_options 13 | @lint_paths = lint_paths 14 | @target_file_paths = rubocop_target_file_paths 15 | @rubocop_results = [] 16 | end 17 | 18 | def command 19 | options.fetch(:command, :base) 20 | end 21 | 22 | def mode 23 | options.fetch(:mode, :update) 24 | end 25 | 26 | def path 27 | options.fetch(:path, ".rubocop_gradual.lock") 28 | end 29 | 30 | def debug? 31 | rubocop_options[:debug] 32 | end 33 | 34 | def display_time? 35 | rubocop_options[:debug] || rubocop_options[:display_time] 36 | end 37 | 38 | def rubocop_config_store 39 | RuboCop::ConfigStore.new.tap do |config_store| 40 | config_store.options_config = rubocop_options[:config] if rubocop_options[:config] 41 | end 42 | end 43 | 44 | private 45 | 46 | def rubocop_target_file_paths 47 | target_finder = RuboCop::TargetFinder.new(rubocop_config_store, rubocop_options) 48 | mode = if rubocop_options[:only_recognized_file_types] 49 | :only_recognized_file_types 50 | else 51 | :all_file_types 52 | end 53 | target_finder 54 | .find(lint_paths, mode) 55 | .map { |path| RuboCop::PathUtil.smart_path(path) } 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/fixtures/project/app/controllers/books_controller.rb: -------------------------------------------------------------------------------- 1 | class BooksController < ApplicationController 2 | before_action :set_book, only: [:show, :edit, :update, :destroy] 3 | 4 | def index 5 | @books = Book.all 6 | end 7 | 8 | def show 9 | end 10 | 11 | def new 12 | @book = Book.new 13 | end 14 | 15 | def edit 16 | end 17 | 18 | def create 19 | @book = Book.new(book_params) 20 | 21 | respond_to do |format| 22 | if @book.save 23 | format.html { redirect_to @book, notice: 'Book was successfully created.' } # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24 | format.json { render :show, status: :created, location: @book } 25 | else 26 | format.html { render :new } 27 | format.json { render json: @book.errors, status: :unprocessable_entity } 28 | end 29 | end 30 | end 31 | 32 | # PATCH/PUT /books/1 33 | # PATCH/PUT /books/1.json 34 | def update 35 | respond_to do |format| 36 | if @book.update(book_params) 37 | format.html { redirect_to @book, notice: 'Book was successfully updated.' } # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 38 | format.json { render :show, status: :ok, location: @book } 39 | else 40 | format.html { render :edit } 41 | format.json { render json: @book.errors, status: :unprocessable_entity } 42 | end 43 | end 44 | end 45 | 46 | # DELETE /books/1 47 | # DELETE /books/1.json 48 | def destroy 49 | @book.destroy 50 | respond_to do |format| 51 | format.html { redirect_to books_url, notice: 'Book was successfully destroyed.' } # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 52 | format.json { head :no_content } 53 | end 54 | end 55 | 56 | private 57 | # Use callbacks to share common setup or constraints between actions. 58 | def set_book 59 | @book = Book.find(params[:id]) 60 | end 61 | 62 | # Never trust parameters from the scary internet, only allow the allow list through. aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 63 | def book_params 64 | params.require(:book).permit(:name) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/results/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "issue" 4 | 5 | module RuboCop 6 | module Gradual 7 | class Results 8 | # File is a representation of a file in a Gradual results. 9 | class File 10 | attr_reader :path, :issues, :file_hash, :state 11 | 12 | def initialize(path:, issues:, hash: nil) 13 | @path = path 14 | @file_hash = hash || djb2a(data) 15 | @issues = prepare_issues(issues).sort 16 | @data = nil 17 | end 18 | 19 | def <=>(other) 20 | path <=> other.path 21 | end 22 | 23 | def changed_issues(other_file) 24 | issues.reject do |result_issue| 25 | other_file.issues.find { |other_issue| result_issue == other_issue } 26 | end 27 | end 28 | 29 | private 30 | 31 | def prepare_issues(issues) 32 | issues.map { |issue| Issue.new(**issue.merge(hash: issue_hash(issue))) } 33 | end 34 | 35 | def issue_hash(issue) 36 | return issue[:hash] if issue[:hash] 37 | 38 | djb2a(fetch_code(issue[:line] - 1, issue[:column] - 1, issue[:length])) 39 | end 40 | 41 | def fetch_code(line, column, length) 42 | code = "" 43 | data.each_line.lazy.drop(line).each_with_index do |str, index| 44 | from = index.zero? ? column : 0 45 | length -= code.length unless index.zero? 46 | code += str[from, length] 47 | 48 | break if code.length >= length 49 | end 50 | code 51 | end 52 | 53 | def data 54 | @data ||= ::File.read(path, encoding: Encoding::UTF_8) 55 | end 56 | 57 | # Function used to calculate the version hash for files and code parts. 58 | # @see http://www.cse.yorku.ca/~oz/hash.html#djb2 59 | def djb2a(str) 60 | str.each_byte.inject(5381) do |hash, b| 61 | ((hash << 5) + hash) ^ b 62 | end & 0xFFFFFFFF 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/process.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "results" 4 | require_relative "lock_file" 5 | require_relative "process/calculate_diff" 6 | require_relative "process/printer" 7 | 8 | module RuboCop 9 | module Gradual 10 | # Process is a class that handles the processing of RuboCop results. 11 | class Process 12 | attr_reader :lock_file, :old_results, :new_results 13 | 14 | def initialize(rubocop_result) 15 | @lock_file = LockFile.new(Configuration.path) 16 | @old_results = lock_file.read_results 17 | @new_results = Results.new(files: rubocop_result) 18 | add_skipped_files_to_new_results! 19 | end 20 | 21 | def call 22 | diff = CalculateDiff.call(new_results, old_results) 23 | printer = Printer.new(diff) 24 | 25 | printer.print_results 26 | if fail_with_outdated_lock?(diff) 27 | printer.print_ci_warning(lock_file.diff(new_results), statistics: diff.statistics) 28 | end 29 | 30 | exit_code = error_code(diff) 31 | sync_lock_file(diff) if exit_code.zero? 32 | exit_code 33 | end 34 | 35 | private 36 | 37 | def fail_with_outdated_lock?(diff) 38 | return false if Configuration.mode != :check 39 | return false if diff.state == :complete && old_results.nil? 40 | 41 | diff.state != :no_changes 42 | end 43 | 44 | def sync_lock_file(diff) 45 | return lock_file.delete if diff.state == :complete 46 | 47 | lock_file.write_results(new_results) 48 | end 49 | 50 | def error_code(diff) 51 | return 1 if fail_with_outdated_lock?(diff) 52 | return 1 if diff.state == :worse && Configuration.mode != :force_update 53 | 54 | 0 55 | end 56 | 57 | def add_skipped_files_to_new_results! 58 | return if Configuration.lint_paths.none? || old_results.nil? 59 | 60 | skipped_files = old_results.files.reject { |file| Configuration.target_file_paths.include?(file.path) } 61 | new_results.files.concat(skipped_files).sort! 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/fixtures/project/was_better.lock: -------------------------------------------------------------------------------- 1 | { 2 | "app/controllers/application_controller.rb:1968181156": [ 3 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 4 | [1, 1, 27, "Style/Documentation: Missing top-level documentation comment for `class ApplicationController`.", 162509677] 5 | ], 6 | "app/controllers/books_controller.rb:2782135345": [ 7 | [2, 1, 1, "Layout/EmptyLinesAroundClassBody: Extra empty line detected at class body beginning.", 177583], 8 | [3, 34, 33, "Style/SymbolArray: Use `%i` or `%I` for an array of symbols.", 4040535139], 9 | [13, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1838757028], 10 | [22, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1835812475], 11 | [32, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 12 | [46, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 13 | [60, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 14 | [65, 3, 7, "Layout/EmptyLinesAroundAccessModifier: Keep a blank line before and after `private`.", 1807578568], 15 | [67, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 16 | [67, 5, 57, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1468944780], 17 | [71, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 18 | [72, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 19 | [72, 5, 65, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1981439488] 20 | ], 21 | "app/models/book.rb:1190628470": [ 22 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 23 | [1, 1, 10, "Style/Documentation: Missing top-level documentation comment for `class Book`.", 3515554338], 24 | [2, 7, 10, "Naming/MethodName: Use snake_case for method names.", 3866179310], 25 | [3, 5, 3, "Lint/UselessAssignment: Useless assignment to variable - `foo`.", 193410979], 26 | [4, 5, 76, "Style/RescueModifier: Avoid using `rescue` in its modifier form.", 3253570103], 27 | [4, 16, 19, "Style/RegexpLiteral: Use `%r` around regular expression.", 4043289893] 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/process/diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RuboCop 4 | module Gradual 5 | class Process 6 | # Diff class represents the difference between two RuboCop Gradual results. 7 | class Diff 8 | attr_reader :files 9 | 10 | def initialize 11 | @files = {} 12 | end 13 | 14 | def state 15 | return :new if new? 16 | return :complete if statistics[:left].zero? 17 | return :worse if statistics[:new].positive? 18 | return :better if statistics[:fixed].positive? 19 | return :updated if statistics[:moved].positive? 20 | 21 | :no_changes 22 | end 23 | 24 | def statistics 25 | @statistics ||= 26 | begin 27 | fixed = count_issues(:fixed) 28 | moved = count_issues(:moved) 29 | new = count_issues(:new) 30 | unchanged = count_issues(:unchanged) 31 | left = moved + new + unchanged 32 | { fixed: fixed, moved: moved, new: new, unchanged: unchanged, left: left } 33 | end 34 | end 35 | 36 | def add_files(files, key) 37 | files.each do |file| 38 | add_issues(file.path, **{ key => file.issues }) 39 | end 40 | self 41 | end 42 | 43 | def add_issues(path, fixed: [], moved: [], new: [], unchanged: []) 44 | @files[path] = { 45 | fixed: fixed, 46 | moved: moved, 47 | new: new, 48 | unchanged: unchanged 49 | } 50 | log_file_issues(path) if Configuration.debug? 51 | self 52 | end 53 | 54 | private 55 | 56 | def new? 57 | statistics[:new].positive? && statistics[:new] == statistics[:left] 58 | end 59 | 60 | def count_issues(key) 61 | @files.values.sum { |v| v[key].size } 62 | end 63 | 64 | def log_file_issues(file_path) 65 | puts "#{file_path}:" 66 | @files[file_path].each do |key, issues| 67 | puts " #{key}: #{issues.size}" 68 | next if issues.empty? 69 | 70 | puts " #{issues.join("\n ")}" 71 | end 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/fixtures/project/full.lock: -------------------------------------------------------------------------------- 1 | { 2 | "app/controllers/application_controller.rb:1968181156": [ 3 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 4 | [1, 1, 27, "Style/Documentation: Missing top-level documentation comment for `class ApplicationController`.", 162509677] 5 | ], 6 | "app/controllers/books_controller.rb:1335398035": [ 7 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 8 | [1, 1, 21, "Style/Documentation: Missing top-level documentation comment for `class BooksController`.", 2020578605], 9 | [2, 34, 33, "Style/SymbolArray: Use `%i` or `%I` for an array of symbols.", 4040535139], 10 | [8, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1838757028], 11 | [15, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1835812475], 12 | [23, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 13 | [37, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 14 | [51, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 15 | [56, 3, 7, "Layout/EmptyLinesAroundAccessModifier: Keep a blank line before and after `private`.", 1807578568], 16 | [58, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 17 | [58, 5, 57, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1468944780], 18 | [62, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 19 | [63, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 20 | [63, 5, 65, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1981439488] 21 | ], 22 | "app/models/book.rb:1190628470": [ 23 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 24 | [1, 1, 10, "Style/Documentation: Missing top-level documentation comment for `class Book`.", 3515554338], 25 | [2, 7, 10, "Naming/MethodName: Use snake_case for method names.", 3866179310], 26 | [3, 5, 3, "Lint/UselessAssignment: Useless assignment to variable - `foo`.", 193410979], 27 | [4, 5, 76, "Style/RescueModifier: Avoid using `rescue` in its modifier form.", 3253570103], 28 | [4, 16, 19, "Style/RegexpLiteral: Use `%r` around regular expression.", 4043289893] 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /spec/fixtures/project/outdated.lock: -------------------------------------------------------------------------------- 1 | { 2 | "app/controllers/application_controller.rb:1968181156": [ 3 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 4 | [1, 1, 27, "Style/Documentation: Missing top-level documentation comment for `class ApplicationController`.", 162509677] 5 | ], 6 | "app/controllers/books_controller.rb:3244390747": [ 7 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 8 | [1, 1, 21, "Style/Documentation: Missing top-level documentation comment for `class BooksController`.", 2020578605], 9 | [2, 34, 33, "Style/SymbolArray: Use `%i` or `%I` for an array of symbols.", 4040535139], 10 | [12, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1838757028], 11 | [21, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1835812475], 12 | [31, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 13 | [45, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 14 | [59, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 15 | [64, 3, 7, "Layout/EmptyLinesAroundAccessModifier: Keep a blank line before and after `private`.", 1807578568], 16 | [66, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 17 | [66, 5, 57, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1468944780], 18 | [70, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 19 | [71, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 20 | [71, 5, 65, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1981439488] 21 | ], 22 | "app/models/book.rb:1190628470": [ 23 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 24 | [1, 1, 10, "Style/Documentation: Missing top-level documentation comment for `class Book`.", 3515554338], 25 | [2, 7, 10, "Naming/MethodName: Use snake_case for method names.", 3866179310], 26 | [3, 5, 3, "Lint/UselessAssignment: Useless assignment to variable - `foo`.", 193410979], 27 | [4, 5, 76, "Style/RescueModifier: Avoid using `rescue` in its modifier form.", 3253570103], 28 | [4, 16, 19, "Style/RegexpLiteral: Use `%r` around regular expression.", 4043289893] 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /spec/fixtures/project/outdated_file.lock: -------------------------------------------------------------------------------- 1 | { 2 | "app/controllers/application_controller.rb:4242424242": [ 3 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 4 | [1, 1, 27, "Style/Documentation: Missing top-level documentation comment for `class ApplicationController`.", 162509677] 5 | ], 6 | "app/controllers/books_controller.rb:1335398035": [ 7 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 8 | [1, 1, 21, "Style/Documentation: Missing top-level documentation comment for `class BooksController`.", 2020578605], 9 | [2, 34, 33, "Style/SymbolArray: Use `%i` or `%I` for an array of symbols.", 4040535139], 10 | [8, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1838757028], 11 | [15, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1835812475], 12 | [23, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 13 | [37, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 14 | [51, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 15 | [56, 3, 7, "Layout/EmptyLinesAroundAccessModifier: Keep a blank line before and after `private`.", 1807578568], 16 | [58, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 17 | [58, 5, 57, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1468944780], 18 | [62, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 19 | [63, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 20 | [63, 5, 65, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1981439488] 21 | ], 22 | "app/models/book.rb:1190628470": [ 23 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 24 | [1, 1, 10, "Style/Documentation: Missing top-level documentation comment for `class Book`.", 3515554338], 25 | [2, 7, 10, "Naming/MethodName: Use snake_case for method names.", 3866179310], 26 | [3, 5, 3, "Lint/UselessAssignment: Useless assignment to variable - `foo`.", 193410979], 27 | [4, 5, 76, "Style/RescueModifier: Avoid using `rescue` in its modifier form.", 3253570103], 28 | [4, 16, 19, "Style/RegexpLiteral: Use `%r` around regular expression.", 4043289893] 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/rake_task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rake" 4 | require "rake/tasklib" 5 | 6 | module RuboCop 7 | module Gradual 8 | # Rake tasks for RuboCop::Gradual. 9 | # 10 | # @example 11 | # require "rubocop/gradual/rake_task" 12 | # RuboCop::Gradual::RakeTask.new 13 | # 14 | class RakeTask < ::Rake::TaskLib 15 | attr_accessor :name, :verbose, :options 16 | 17 | def initialize(name = :rubocop_gradual, *args, &task_block) 18 | super() 19 | @name = name 20 | @verbose = true 21 | @options = [] 22 | define(args, &task_block) 23 | end 24 | 25 | private 26 | 27 | def define(args, &task_block) 28 | desc "Run RuboCop Gradual" unless ::Rake.application.last_description 29 | define_task(name, nil, args, &task_block) 30 | setup_subtasks(args, &task_block) 31 | end 32 | 33 | def setup_subtasks(args, &task_block) 34 | namespace(name) do 35 | desc "Run RuboCop Gradual with autocorrect (only when it's safe)." 36 | define_task(:autocorrect, "--autocorrect", args, &task_block) 37 | 38 | desc "Run RuboCop Gradual with autocorrect (safe and unsafe)." 39 | define_task(:autocorrect_all, "--autocorrect-all", args, &task_block) 40 | 41 | desc "Run RuboCop Gradual to check the lock file." 42 | define_task(:check, "--check", args, &task_block) 43 | 44 | desc "Run RuboCop Gradual to force update the lock file." 45 | define_task(:force_update, "--force-update", args, &task_block) 46 | end 47 | end 48 | 49 | def define_task(name, option, args, &task_block) 50 | task(name, *args) do |_, task_args| 51 | RakeFileUtils.verbose(verbose) do 52 | yield(*[self, task_args].slice(0, task_block.arity)) if task_block 53 | run_cli(verbose, option) 54 | end 55 | end 56 | end 57 | 58 | def run_cli(verbose, option) 59 | require "rubocop-gradual" 60 | 61 | cli = CLI.new 62 | puts "Running RuboCop Gradual..." if verbose 63 | result = cli.run(full_options(option)) 64 | abort("RuboCop Gradual failed!") if result.nonzero? 65 | end 66 | 67 | def full_options(option) 68 | option ? options.flatten.unshift(option) : options.flatten 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/fixtures/project/was_worse.lock: -------------------------------------------------------------------------------- 1 | { 2 | "app/controllers/application_controller.rb:1968181156": [ 3 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 4 | [1, 1, 27, "Style/Documentation: Missing top-level documentation comment for `class ApplicationController`.", 162509677] 5 | ], 6 | "app/controllers/books_controller.rb:1335398035": [ 7 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 8 | [1, 1, 21, "Style/Documentation: Missing top-level documentation comment for `class BooksController`.", 2020578605], 9 | [2, 34, 33, "Style/SymbolArray: Use `%i` or `%I` for an array of symbols.", 4040535139], 10 | [8, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1838757028], 11 | [15, 3, 14, "Style/EmptyMethod: Put empty method definitions on a single line.", 1835812475], 12 | [23, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 13 | [37, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 14 | [51, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 15 | [56, 3, 7, "Layout/EmptyLinesAroundAccessModifier: Keep a blank line before and after `private`.", 1807578568], 16 | [58, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 17 | [58, 5, 57, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1468944780], 18 | [62, 121, 1, "Layout/LineLength: Line is too long. [121/120]", 177604], 19 | [63, 1, 4, "Layout/IndentationWidth: Use 2 (not 4) spaces for indentation.", 2085287685], 20 | [63, 5, 65, "Layout/IndentationConsistency: Inconsistent indentation detected.", 1981439488] 21 | ], 22 | "app/models/book.rb:3090658385": [ 23 | [1, 1, 1, "Style/FrozenStringLiteralComment: Missing frozen string literal comment.", 177606], 24 | [1, 1, 10, "Style/Documentation: Missing top-level documentation comment for `class Book`.", 3515554338], 25 | [2, 7, 10, "Naming/MethodName: Use snake_case for method names.", 3866179310], 26 | [2, 17, 2, "Style/DefWithParentheses: Omit the parentheses in defs when the method doesn't accept any arguments.", 5859076], 27 | [3, 5, 3, "Lint/UselessAssignment: Useless assignment to variable - `foo`.", 193410979], 28 | [4, 5, 76, "Style/RescueModifier: Avoid using `rescue` in its modifier form.", 3253570103], 29 | [4, 16, 19, "Style/RegexpLiteral: Use `%r` around regular expression.", 4043289893], 30 | [6, 1, 1, "Layout/EmptyLinesAroundClassBody: Extra empty line detected at class body end.", 177583] 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/process/printer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RuboCop 4 | module Gradual 5 | class Process 6 | # Printer class prints the results of the RuboCop Gradual process. 7 | class Printer 8 | def initialize(diff) 9 | @diff = diff 10 | end 11 | 12 | def print_results 13 | puts diff.statistics if Configuration.debug? 14 | 15 | send :"print_#{diff.state}" 16 | end 17 | 18 | def print_ci_warning(diff, statistics:) 19 | puts <<~MSG 20 | \n#{bold("Unexpected Changes!")} 21 | 22 | RuboCop Gradual lock file is outdated, to fix this message: 23 | - Run `rubocop-gradual` locally and commit the results#{ 24 | if statistics[:unchanged] != statistics[:left] 25 | ", or\n- EVEN BETTER: before doing the above, try to fix the remaining issues in those files!" 26 | end 27 | } 28 | 29 | #{bold("`#{Configuration.path}` diff:")} 30 | 31 | #{diff.to_s(ARGV.include?("--no-color") ? :text : :color)} 32 | MSG 33 | end 34 | 35 | private 36 | 37 | attr_reader :diff 38 | 39 | def print_complete 40 | puts bold("RuboCop Gradual is complete!") 41 | puts "Removing `#{Configuration.path}` lock file..." if diff.statistics[:fixed].positive? 42 | end 43 | 44 | def print_updated 45 | puts bold("RuboCop Gradual got its results updated.") 46 | end 47 | 48 | def print_no_changes 49 | puts bold("RuboCop Gradual got no changes.") 50 | end 51 | 52 | def print_new 53 | issues_left = diff.statistics[:left] 54 | puts bold("RuboCop Gradual got results for the first time. #{issues_left} issue(s) found.") 55 | puts "Don't forget to commit `#{Configuration.path}` log file." 56 | end 57 | 58 | def print_better 59 | issues_left = diff.statistics[:left] 60 | issues_fixed = diff.statistics[:fixed] 61 | puts bold("RuboCop Gradual got #{issues_fixed} issue(s) fixed, #{issues_left} left. Keep going!") 62 | end 63 | 64 | def print_worse 65 | puts bold("Uh oh, RuboCop Gradual got worse:") 66 | print_new_issues 67 | puts bold("Force updating lock file...") if Configuration.mode == :force_update 68 | end 69 | 70 | def print_new_issues 71 | diff.files.each do |path, issues| 72 | next if issues[:new].empty? 73 | 74 | puts "-> #{path} (#{issues[:new].size} new issues)" 75 | issues[:new].each do |issue| 76 | puts " (line #{issue.line}) \"#{issue.message}\"" 77 | end 78 | end 79 | end 80 | 81 | def bold(str) 82 | rainbow.wrap(str).bright 83 | end 84 | 85 | def rainbow 86 | @rainbow ||= Rainbow.new.tap do |r| 87 | r.enabled = false if ARGV.include?("--no-color") 88 | end 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/process/calculate_diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "diff" 4 | require_relative "matcher" 5 | 6 | module RuboCop 7 | module Gradual 8 | class Process 9 | # CalculateDiff calculates the difference between two RuboCop Gradual results. 10 | module CalculateDiff 11 | class << self 12 | def call(new_result, old_result) 13 | return Diff.new.add_files(new_result.files, :new) if old_result.nil? 14 | 15 | diff_results(new_result, old_result) 16 | end 17 | 18 | private 19 | 20 | def diff_results(new_result, old_result) 21 | new_files, fixed_files, path_files_match, moved_files_match = split_files(new_result, old_result) 22 | 23 | diff = Diff.new.add_files(new_files, :new).add_files(fixed_files, :fixed) 24 | path_files_match.chain(moved_files_match).each do |result_file, old_file| 25 | diff_issues(diff, result_file, old_file) 26 | end 27 | 28 | diff 29 | end 30 | 31 | def split_files(new_result, old_result) 32 | path_files_match = Matcher.new(new_result.files, old_result.files, :path) 33 | new_or_moved_files = path_files_match.unmatched_keys 34 | fixed_or_moved_files = path_files_match.unmatched_values 35 | 36 | moved_files_match = Matcher.new(new_or_moved_files, fixed_or_moved_files, :file_hash) 37 | new_files = moved_files_match.unmatched_keys 38 | fixed_files = moved_files_match.unmatched_values 39 | 40 | [new_files, fixed_files, path_files_match, moved_files_match] 41 | end 42 | 43 | def diff_issues(diff, result_file, old_file) 44 | fixed_or_moved = old_file.changed_issues(result_file) 45 | new_or_moved = result_file.changed_issues(old_file) 46 | moved, fixed = split_issues(fixed_or_moved, new_or_moved) 47 | new = new_or_moved - moved 48 | unchanged = result_file.issues - new - moved 49 | 50 | if result_file.file_hash != old_file.file_hash && fixed.empty? && new.empty? && moved.empty? 51 | moved = unchanged 52 | unchanged = [] 53 | end 54 | 55 | diff.add_issues(result_file.path, fixed: fixed, moved: moved, new: new, unchanged: unchanged) 56 | end 57 | 58 | def split_issues(fixed_or_moved_issues, new_or_moved_issues) 59 | possibilities = new_or_moved_issues.dup 60 | fixed_issues = [] 61 | moved_issues = [] 62 | fixed_or_moved_issues.each do |fixed_or_moved_issue| 63 | best = best_possibility(fixed_or_moved_issue, possibilities) 64 | next fixed_issues << fixed_or_moved_issue if best.nil? 65 | 66 | moved_issues << possibilities.delete(best) 67 | end 68 | [moved_issues, fixed_issues] 69 | end 70 | 71 | def best_possibility(issue, possible_issues) 72 | possibilities = possible_issues.select do |possible_issue| 73 | possible_issue.code_hash == issue.code_hash 74 | end 75 | possibilities.min_by { |possibility| issue.distance(possibility) } 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog], 6 | and this project adheres to [Semantic Versioning]. 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.3.6] - 2024-07-21 11 | 12 | ### Fixed 13 | 14 | - Don't fail `--check` when no issues and no lock file present. ([@skryukov]) 15 | 16 | ## [0.3.5] - 2024-06-24 17 | 18 | ### Added 19 | 20 | - Add support for the RuboCop `--list` option. ([@skryukov]) 21 | 22 | ### Fixed 23 | 24 | - Respect files passed to RuboCop in required mode. ([@skryukov]) 25 | - Exclude deleted files when running `--staged`. ([@dmorgan-fa]) 26 | - Don't show "EVEN BETTER" instruction when all issues are fixed. ([@skryukov]) 27 | 28 | ## [0.3.4] - 2023-10-26 29 | 30 | ### Fixed 31 | 32 | - Use JSON.dump instead of to_json for stable results encoding. ([@skryukov]) 33 | 34 | ## [0.3.3] - 2023-10-18 35 | 36 | ### Fixed 37 | 38 | - Throw an error when the `--check` option is used and file hash is outdated. ([@skryukov]) 39 | - Wrap RuboCop errors to make output more pleasant. ([@skryukov]) 40 | 41 | ## [0.3.2] - 2023-10-10 42 | 43 | ### Fixed 44 | 45 | - Handle syntax errors in inspected files. ([@skryukov]) 46 | 47 | ## [0.3.1] - 2022-11-29 48 | 49 | ### Fixed 50 | 51 | - More straightforward way of including RuboCop patch for Require mode. ([@skryukov]) 52 | 53 | ## [0.3.0] - 2022-10-26 54 | 55 | ### Added 56 | 57 | - Partial linting (experimental). ([@skryukov]) 58 | 59 | Partial linting is useful when you want to run RuboCop Gradual on a subset of files, for example, on changed files in a pull request: 60 | 61 | ```shell 62 | rubocop-gradual path/to/file # run `rubocop-gradual` on a subset of files 63 | rubocop-gradual --staged # run `rubocop-gradual` on staged files 64 | rubocop-gradual --unstaged # run `rubocop-gradual` on unstaged files 65 | rubocop-gradual --commit origin/main # run `rubocop-gradual` on changed files since the commit 66 | 67 | # it's possible to combine options with autocorrect: 68 | rubocop-gradual --staged --autocorrect # run `rubocop-gradual` with autocorrect on staged files 69 | ``` 70 | 71 | - Require mode (experimental). ([@skryukov]) 72 | 73 | RuboCop Gradual can be used in "Require mode", which is a way to replace `rubocop` with `rubocop-gradual`: 74 | 75 | ```yaml 76 | # .rubocop.yml 77 | 78 | require: 79 | - rubocop-gradual 80 | ``` 81 | 82 | - Built-in Rake tasks. ([@skryukov]) 83 | 84 | ```ruby 85 | # Rakefile 86 | require "rubocop/gradual/rake_task" 87 | 88 | RuboCop::Gradual::RakeTask.new 89 | ``` 90 | 91 | ### Fixed 92 | 93 | - Issues with the same location ordered by the message. ([@skryukov]) 94 | 95 | ## [0.2.0] - 2022-07-26 96 | 97 | ### Added 98 | 99 | - Autocorrection options. ([@skryukov]) 100 | Run `rubocop-gradual -a` and `rubocop-gradual -A` to autocorrect new and changed files and then update the lock file. 101 | 102 | ### Changed 103 | 104 | - Rename `--ci` to `--check` option. ([@skryukov]) 105 | 106 | - Rename `-u, --update` to `-U, --force-update` option. ([@skryukov]) 107 | 108 | ## [0.1.1] - 2022-07-05 109 | 110 | ### Changed 111 | 112 | - `parallel` gem is used to speed up results parsing. ([@skryukov]) 113 | 114 | ### Fixed 115 | 116 | - Fixed multiline issues hash calculation. ([@skryukov]) 117 | 118 | ## [0.1.0] - 2022-07-03 119 | 120 | ### Added 121 | 122 | - Initial implementation. ([@skryukov]) 123 | 124 | [@dmorgan-fa]: https://github.com/dmorgan-fa 125 | [@skryukov]: https://github.com/skryukov 126 | 127 | [Unreleased]: https://github.com/skryukov/rubocop-gradual/compare/v0.3.6...HEAD 128 | [0.3.6]: https://github.com/skryukov/rubocop-gradual/compare/v0.3.5...v0.3.6 129 | [0.3.5]: https://github.com/skryukov/rubocop-gradual/compare/v0.3.4...v0.3.5 130 | [0.3.4]: https://github.com/skryukov/rubocop-gradual/compare/v0.3.3...v0.3.4 131 | [0.3.3]: https://github.com/skryukov/rubocop-gradual/compare/v0.3.2...v0.3.3 132 | [0.3.2]: https://github.com/skryukov/rubocop-gradual/compare/v0.3.1...v0.3.2 133 | [0.3.1]: https://github.com/skryukov/rubocop-gradual/compare/v0.3.0...v0.3.1 134 | [0.3.0]: https://github.com/skryukov/rubocop-gradual/compare/v0.2.0...v0.3.0 135 | [0.2.0]: https://github.com/skryukov/rubocop-gradual/compare/v0.1.1...v0.2.0 136 | [0.1.1]: https://github.com/skryukov/rubocop-gradual/compare/v0.1.0...v0.1.1 137 | [0.1.0]: https://github.com/skryukov/rubocop-gradual/commits/v0.1.0 138 | 139 | [Keep a Changelog]: https://keepachangelog.com/en/1.0.0/ 140 | [Semantic Versioning]: https://semver.org/spec/v2.0.0.html 141 | -------------------------------------------------------------------------------- /lib/rubocop/gradual/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "shellwords" 4 | 5 | require_relative "git" 6 | 7 | module RuboCop 8 | module Gradual 9 | # Options class defines RuboCop Gradual cli options. 10 | class Options 11 | AUTOCORRECT_KEY = 12 | if Gem::Version.new(RuboCop::Version::STRING) >= Gem::Version.new("1.30") 13 | :autocorrect 14 | else 15 | :auto_correct 16 | end 17 | 18 | def initialize 19 | @options = {} 20 | end 21 | 22 | def parse(args) 23 | parser = define_options 24 | gradual_args, rubocop_args = filter_args(parser, args_from_file + args) 25 | @rubocop_options, @lint_paths = RuboCop::Options.new.parse(rubocop_args) 26 | parser.parse(gradual_args) 27 | 28 | [@options, @rubocop_options, @lint_paths] 29 | end 30 | 31 | private 32 | 33 | def define_options 34 | OptionParser.new do |opts| 35 | define_mode_options(opts) 36 | define_gradual_options(opts) 37 | define_lint_paths_options(opts) 38 | 39 | define_info_options(opts) 40 | end 41 | end 42 | 43 | def define_mode_options(opts) 44 | opts.on("-U", "--force-update", "Force update Gradual lock file.") { @options[:mode] = :force_update } 45 | opts.on("-u", "--update", "Same as --force-update (deprecated).") do 46 | warn "-u, --update is deprecated. Use -U, --force-update instead." 47 | @options[:mode] = :force_update 48 | end 49 | 50 | opts.on("--check", "Check Gradual lock file is up-to-date.") { @options[:mode] = :check } 51 | opts.on("--ci", "Same as --check (deprecated).") do 52 | warn "--ci is deprecated. Use --check instead." 53 | @options[:mode] = :check 54 | end 55 | end 56 | 57 | def define_gradual_options(opts) 58 | opts.on("-a", "--autocorrect", "Autocorrect offenses (only when it's safe).") do 59 | @rubocop_options[AUTOCORRECT_KEY] = true 60 | @rubocop_options[:"safe_#{AUTOCORRECT_KEY}"] = true 61 | @options[:command] = :autocorrect 62 | end 63 | opts.on("-A", "--autocorrect-all", "Autocorrect offenses (safe and unsafe).") do 64 | @rubocop_options[AUTOCORRECT_KEY] = true 65 | @options[:command] = :autocorrect 66 | end 67 | 68 | opts.on("--gradual-file FILE", "Specify Gradual lock file.") { |path| @options[:path] = path } 69 | end 70 | 71 | def define_lint_paths_options(opts) 72 | opts.on("--unstaged", "Lint unstaged files.") do 73 | @lint_paths = git_lint_paths(:unstaged) 74 | end 75 | opts.on("--staged", "Lint staged files.") do 76 | @lint_paths = git_lint_paths(:staged) 77 | end 78 | opts.on("--commit COMMIT", "Lint files changed since the commit.") do |commit| 79 | @lint_paths = git_lint_paths(commit) 80 | end 81 | end 82 | 83 | def define_info_options(opts) 84 | opts.on("-v", "--version", "Display version.") do 85 | puts "rubocop-gradual: #{VERSION}, rubocop: #{RuboCop::Version.version}" 86 | exit 87 | end 88 | 89 | opts.on("-h", "--help", "Prints this help.") do 90 | puts opts 91 | exit 92 | end 93 | end 94 | 95 | def git_lint_paths(commit) 96 | @rubocop_options[:only_recognized_file_types] = true 97 | RuboCop::Gradual::Git.paths_by(commit) 98 | end 99 | 100 | def filter_args(parser, original_args, self_args = []) 101 | extract_all_args(parser).each do |option| 102 | loop do 103 | break unless (i = original_args.index { |a| a.start_with?(option[:name]) }) 104 | 105 | self_args << original_args.delete_at(i) 106 | next if option[:no_args] || original_args.size <= i || original_args[i].start_with?("-") 107 | 108 | self_args << original_args.delete_at(i) 109 | end 110 | end 111 | [self_args, original_args] 112 | end 113 | 114 | def extract_all_args(parser) 115 | parser.top.list.reduce([]) do |res, option| 116 | no_args = option.is_a?(OptionParser::Switch::NoArgument) 117 | options = (option.long + option.short).map { |o| { name: o, no_args: no_args } } 118 | res + options 119 | end 120 | end 121 | 122 | def args_from_file 123 | if File.exist?(".rubocop-gradual") && !File.directory?(".rubocop-gradual") 124 | File.read(".rubocop-gradual").shellsplit 125 | else 126 | [] 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/rubocop/gradual/rake_task_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubocop/gradual/rake_task" 4 | 5 | RSpec.describe RuboCop::Gradual::RakeTask do 6 | before { Rake::Task.clear } 7 | 8 | after { Rake::Task.clear } 9 | 10 | describe "defining tasks" do 11 | it "creates a rubocop_gradual task and a rubocop_gradual autocorrect task" do 12 | described_class.new 13 | 14 | expect(Rake::Task.task_defined?(:rubocop_gradual)).to be true 15 | expect(Rake::Task.task_defined?("rubocop_gradual:autocorrect")).to be true 16 | end 17 | 18 | it "creates a named task and a named autocorrect task" do 19 | described_class.new(:lint_lib) 20 | 21 | expect(Rake::Task.task_defined?(:lint_lib)).to be true 22 | expect(Rake::Task.task_defined?("lint_lib:autocorrect")).to be true 23 | end 24 | 25 | it "creates a rubocop task and a rubocop autocorrect_all task" do 26 | described_class.new 27 | 28 | expect(Rake::Task.task_defined?(:rubocop_gradual)).to be true 29 | expect(Rake::Task.task_defined?("rubocop_gradual:autocorrect_all")).to be true 30 | end 31 | 32 | it "creates a named task and a named autocorrect_all task" do 33 | described_class.new(:lint_lib) 34 | 35 | expect(Rake::Task.task_defined?(:lint_lib)).to be true 36 | expect(Rake::Task.task_defined?("lint_lib:autocorrect_all")).to be true 37 | end 38 | end 39 | 40 | describe "running tasks" do 41 | before do 42 | $stdout = StringIO.new 43 | $stderr = StringIO.new 44 | end 45 | 46 | after do 47 | $stdout = STDOUT 48 | $stderr = STDERR 49 | end 50 | 51 | let!(:cli) do 52 | instance_double(RuboCop::Gradual::CLI, run: cli_result).tap do |cli| 53 | allow(RuboCop::Gradual::CLI).to receive(:new).and_return(cli) 54 | end 55 | end 56 | let(:cli_result) { 0 } 57 | 58 | it "runs with default options" do 59 | described_class.new 60 | 61 | Rake::Task["rubocop_gradual"].execute 62 | 63 | expect(cli).to have_received(:run).with([]) 64 | end 65 | 66 | context "with specified options" do 67 | let(:rake_task) do 68 | described_class.new do |task| 69 | task.options = ["--display-time"] 70 | task.verbose = false 71 | end 72 | end 73 | 74 | it "runs with specified options" do 75 | rake_task 76 | Rake::Task["rubocop_gradual"].execute 77 | 78 | expect(cli).to have_received(:run).with(%w[--display-time]) 79 | end 80 | end 81 | 82 | it "allows nested arrays inside options" do 83 | described_class.new do |task| 84 | task.options = [%w[--gradual-file custom_gradual_file.lock]] 85 | end 86 | 87 | Rake::Task["rubocop_gradual"].execute 88 | 89 | expect(cli).to have_received(:run).with(%w[--gradual-file custom_gradual_file.lock]) 90 | end 91 | 92 | context "when cli returns non-zero" do 93 | let(:cli_result) { 1 } 94 | 95 | it "raises error" do 96 | described_class.new 97 | expect { Rake::Task["rubocop_gradual"].execute }.to raise_error(SystemExit) 98 | end 99 | end 100 | 101 | describe "autocorrect" do 102 | it "runs with --autocorrect" do 103 | described_class.new 104 | Rake::Task["rubocop_gradual:autocorrect"].execute 105 | 106 | expect(cli).to have_received(:run).with(["--autocorrect"]) 107 | end 108 | 109 | it "runs with --autocorrect-all" do 110 | described_class.new 111 | Rake::Task["rubocop_gradual:autocorrect_all"].execute 112 | 113 | expect(cli).to have_received(:run).with(["--autocorrect-all"]) 114 | end 115 | 116 | context "with specified options" do 117 | let(:rake_task) do 118 | described_class.new do |task| 119 | task.options = ["--debug"] 120 | task.verbose = false 121 | end 122 | end 123 | 124 | it "runs with specified options" do 125 | rake_task 126 | Rake::Task["rubocop_gradual:autocorrect_all"].execute 127 | 128 | expect(cli).to have_received(:run).with(%w[--autocorrect-all --debug]) 129 | end 130 | end 131 | end 132 | 133 | describe "check" do 134 | it "runs with --check" do 135 | described_class.new 136 | 137 | Rake::Task["rubocop_gradual:check"].execute 138 | 139 | expect(cli).to have_received(:run).with(["--check"]) 140 | end 141 | end 142 | 143 | describe "force_update" do 144 | it "runs with --force-update" do 145 | described_class.new 146 | 147 | Rake::Task["rubocop_gradual:force_update"].execute 148 | 149 | expect(cli).to have_received(:run).with(["--force-update"]) 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RuboCop Gradual 2 | 3 | [![Gem Version](https://badge.fury.io/rb/rubocop-gradual.svg)](https://rubygems.org/gems/rubocop-gradual) 4 | [![Build](https://github.com/skryukov/rubocop-gradual/workflows/Build/badge.svg)](https://github.com/skryukov/rubocop-gradual/actions) 5 | 6 | RuboCop Gradual is a tool that helps track down and fix RuboCop offenses in your code gradually. It's a more flexible alternative to RuboCop's `--auto-gen-config` option. 7 | 8 | RuboCop Gradual: 9 | 10 | - generates the lock file with all RuboCop offenses and uses hashes to track each offense **line by line** 11 | - **automatically** updates the lock file on every successful run, but returns errors on new offenses 12 | - does not prevent your editor from **showing ignored offenses** 13 | 14 | Gain full control of gradual improvements: just add `rubocop-gradual` and use it as proxy for `rubocop`. 15 | 16 | 17 | Sponsored by Evil Martians 18 | 19 | 20 | ## Installation 21 | 22 | Install the gem and add to the application's Gemfile by executing: 23 | 24 | $ bundle add rubocop-gradual 25 | 26 | Run RuboCop Gradual to create a lock file (defaults to `.rubocop_gradual.lock`): 27 | 28 | $ rubocop-gradual 29 | 30 | Commit the lock file to the project repository to keep track of all non-fixed offenses. 31 | 32 | Run `rubocop-gradual` before commiting changes to update the lock file. RuboCop Gradual will keep updating the lock file to keep track of all non-fixed offenses, but it will throw an error if there are any new offenses. 33 | 34 | ## Usage 35 | 36 | Proposed workflow: 37 | 38 | - Remove `rubocop_todo.yml` if it exists. 39 | - Run `rubocop-gradual` to generate a lock file and commit it to the project repository. 40 | - Add `rubocop-gradual --check` to your CI pipeline instead of `rubocop`/`standard`. It will throw an error if the lock file is out of date. 41 | - Run `rubocop-gradual` to update the lock file, or `rubocop-gradual -a` to run autocorrection for all new and changed files and then update the lock file. 42 | - Optionally, add `rubocop-gradual` as a pre-commit hook to your repository (using [lefthook], for example). 43 | - RuboCop Gradual will throw an error on any new offense, but if you really want to force update the lock file, run `rubocop-gradual --force-update`. 44 | 45 | ## Available options 46 | 47 | ``` 48 | -U, --force-update Force update Gradual lock file. 49 | --check Check Gradual lock file is up-to-date. 50 | -a, --autocorrect Autocorrect offenses (only when it's safe). 51 | -A, --autocorrect-all Autocorrect offenses (safe and unsafe). 52 | --gradual-file FILE Specify Gradual lock file. 53 | -v, --version Display version. 54 | -h, --help Prints this help. 55 | ``` 56 | 57 | ## Rake tasks 58 | 59 | To use built-in Rake tasks add the following to your Rakefile: 60 | 61 | ```ruby 62 | # Rakefile 63 | require "rubocop/gradual/rake_task" 64 | 65 | RuboCop::Gradual::RakeTask.new 66 | ``` 67 | 68 | This will add rake tasks: 69 | 70 | ``` 71 | bundle exec rake -T 72 | rake rubocop_gradual # Run RuboCop Gradual 73 | rake rubocop_gradual:autocorrect # Run RuboCop Gradual with autocorrect (only when it's safe) 74 | rake rubocop_gradual:autocorrect_all # Run RuboCop Gradual with autocorrect (safe and unsafe) 75 | rake rubocop_gradual:check # Run RuboCop Gradual to check the lock file 76 | rake rubocop_gradual:force_update # Run RuboCop Gradual to force update the lock file 77 | ``` 78 | 79 | It's possible to customize the Rake task name and options: 80 | 81 | ```ruby 82 | # Rakefile 83 | 84 | require "rubocop/gradual/rake_task" 85 | 86 | RuboCop::Gradual::RakeTask.new(:custom_task_name) do |task| 87 | task.options = %w[--gradual-file custom_gradual_file.lock] 88 | task.verbose = false 89 | end 90 | ``` 91 | 92 | ## Partial linting (experimental) 93 | 94 | RuboCop Gradual supports partial linting. It's useful when you want to run RuboCop Gradual on a subset of files, for example, on changed files in a pull request: 95 | 96 | ```shell 97 | rubocop-gradual path/to/file # run `rubocop-gradual` on a subset of files 98 | rubocop-gradual --staged # run `rubocop-gradual` on staged files 99 | rubocop-gradual --unstaged # run `rubocop-gradual` on unstaged files 100 | rubocop-gradual --commit origin/main # run `rubocop-gradual` on changed files since the commit 101 | 102 | # it's possible to combine options with autocorrect: 103 | rubocop-gradual --staged --autocorrect # run `rubocop-gradual` with autocorrect on staged files 104 | ``` 105 | 106 | ## Require mode (experimental) 107 | 108 | RuboCop Gradual can be used in "Require mode", which is a way to replace `rubocop` with `rubocop-gradual`: 109 | 110 | ```yaml 111 | # .rubocop.yml 112 | 113 | require: 114 | - rubocop/gradual/patch 115 | ``` 116 | 117 | Now base `rubocop` command will run `rubocop-gradual`: 118 | 119 | ```shell 120 | rubocop # run `rubocop-gradual` 121 | rubocop -a # run `rubocop-gradual` with autocorrect (only when it's safe) 122 | rubocop -A # run `rubocop-gradual` with autocorrect (safe and unsafe) 123 | rubocop gradual check # run `rubocop-gradual` to check the lock file 124 | rubocop gradual force_update # run `rubocop-gradual` to force update the lock file 125 | ``` 126 | 127 | To set a custom path to Gradual lock file, add `--gradual-file FILE` to a special `.rubocop-gradual` file: 128 | 129 | ``` 130 | # .rubocop-gradual 131 | --rubocop-gradual-file path/to/my_lock_file.lock 132 | ``` 133 | 134 | To temporarily disable RuboCop Gradual, prepend command with `NO_GRADUAL=1`: 135 | 136 | ```shell 137 | NO_GRADUAL=1 rubocop # run `rubocop` 138 | ``` 139 | 140 | ## Alternatives 141 | 142 | - [RuboCop TODO file]. Comes out of the box with RuboCop. Provides a way to ignore offenses on the file level, which is problematic since it is possible to introduce new offenses without any signal from linter. 143 | - [Pronto]. Checks for offenses only on changed files. Does not provide a way to temporarily ignore offenses. 144 | - [Betterer]. Universal test runner that helps make incremental improvements witten in JavaScript. RuboCop Gradual is highly inspired by Betterer. 145 | 146 | ## Development 147 | 148 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 149 | 150 | To install this gem onto your local machine, run `bundle exec rake install`. 151 | 152 | ## Contributing 153 | 154 | Bug reports and pull requests are welcome on GitHub at https://github.com/skryukov/rubocop-gradual 155 | 156 | ## License 157 | 158 | The gem is available as open source under the terms of the [MIT License]. 159 | 160 | [lefthook]: https://github.com/evilmartians/lefthook 161 | [RuboCop TODO file]: https://docs.rubocop.org/rubocop/configuration.html#automatically-generated-configuration 162 | [Pronto]: https://github.com/prontolabs/pronto-rubocop 163 | [Betterer]: https://github.com/phenomnomnominal/betterer 164 | [MIT License]: https://opensource.org/licenses/MIT 165 | -------------------------------------------------------------------------------- /spec/rubocop/gradual_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RuboCop::Gradual, :aggregate_failures do 4 | subject(:gradual_cli) { RuboCop::Gradual::CLI.new.run([*options, actual_lock_path]) } 5 | 6 | around do |example| 7 | Dir.mktmpdir do |tmpdir| 8 | tmpdir = File.realpath(tmpdir) 9 | project_path = File.join("spec/fixtures/project") 10 | FileUtils.cp_r(project_path, tmpdir) 11 | 12 | Dir.chdir(File.join(tmpdir, "project")) { example.run } 13 | end 14 | end 15 | 16 | before do 17 | $stdout = StringIO.new 18 | $stderr = StringIO.new 19 | end 20 | 21 | after do 22 | $stdout = STDOUT 23 | $stderr = STDERR 24 | end 25 | 26 | shared_examples "error with --check option" do |with_extended_steps: true| 27 | context "with --check option" do 28 | let(:options) { super().unshift("--check") } 29 | 30 | it "doesn't update file" do 31 | expect { gradual_cli }.not_to( 32 | change do 33 | File.exist?(actual_lock_path) && File.read(actual_lock_path, encoding: Encoding::UTF_8) 34 | end 35 | ) 36 | end 37 | 38 | it "returns error" do 39 | expect(gradual_cli).to eq(1) 40 | expect($stdout.string).to include("Unexpected Changes!") 41 | .and(with_extended_steps ? include("EVEN BETTER") : not_include("EVEN BETTER")) 42 | end 43 | end 44 | end 45 | 46 | let(:options) { %w[--gradual-file] } 47 | 48 | let(:actual_lock_path) { File.expand_path("result.lock") } 49 | let(:actual_data) { File.read(actual_lock_path, encoding: Encoding::UTF_8) } 50 | 51 | let(:expected_lock_path) { File.expand_path("full.lock") } 52 | let(:expected_data) { File.read(expected_lock_path, encoding: Encoding::UTF_8) } 53 | 54 | it "writes full file for the first time" do 55 | expect(gradual_cli).to eq(0) 56 | expect(actual_data).to eq(expected_data) 57 | expect($stdout.string).to include("RuboCop Gradual got results for the first time. 22 issue(s) found.") 58 | end 59 | 60 | include_examples "error with --check option" 61 | 62 | context "when the lock file is outdated" do 63 | let(:actual_lock_path) { File.expand_path("outdated.lock") } 64 | let(:expected_lock_path) { File.expand_path("full.lock") } 65 | 66 | it "updates file" do 67 | expect(gradual_cli).to eq(0) 68 | expect(actual_data).to eq(expected_data) 69 | expect($stdout.string).to include("RuboCop Gradual got its results updated.") 70 | end 71 | 72 | include_examples "error with --check option" 73 | end 74 | 75 | context "when the lock file is outdated but only by file hash" do 76 | let(:actual_lock_path) { File.expand_path("outdated_file.lock") } 77 | let(:expected_lock_path) { File.expand_path("full.lock") } 78 | 79 | it "updates file" do 80 | expect(gradual_cli).to eq(0) 81 | expect(actual_data).to eq(expected_data) 82 | expect($stdout.string).to include("RuboCop Gradual got its results updated.") 83 | end 84 | 85 | include_examples "error with --check option" 86 | end 87 | 88 | context "when the lock file is the same" do 89 | let(:actual_lock_path) { expected_lock_path } 90 | 91 | it "returns success and doesn't update file" do 92 | expect(gradual_cli).to eq(0) 93 | expect(actual_data).to eq(expected_data) 94 | expect($stdout.string).to include("RuboCop Gradual got no changes.") 95 | end 96 | 97 | context "with --check option" do 98 | let(:options) { super().unshift("--check") } 99 | 100 | it "returns success and doesn't update file" do 101 | expect(gradual_cli).to eq(0) 102 | expect(actual_data).to eq(expected_data) 103 | expect($stdout.string).to include("RuboCop Gradual got no changes.") 104 | end 105 | end 106 | end 107 | 108 | context "when the lock file was better before" do 109 | let(:actual_lock_path) { File.expand_path("was_better.lock") } 110 | let(:expected_lock_path) { actual_lock_path } 111 | 112 | it "returns error and does not update file" do 113 | expect(gradual_cli).to eq(1) 114 | expect(actual_data).to eq(expected_data) 115 | expect($stdout.string).to include("Uh oh, RuboCop Gradual got worse:") 116 | .and include("app/controllers/books_controller.rb (2 new issues)") 117 | end 118 | 119 | context "with --check option" do 120 | let(:options) { super().unshift("--check") } 121 | 122 | it "returns error and does not update file" do 123 | expect(gradual_cli).to eq(1) 124 | expect(actual_data).to eq(expected_data) 125 | expect($stdout.string).to include("Uh oh, RuboCop Gradual got worse:") 126 | .and include("app/controllers/books_controller.rb (2 new issues)") 127 | end 128 | end 129 | 130 | context "with --force-update option" do 131 | let(:options) { super().unshift("--force-update") } 132 | let(:expected_lock_path) { File.expand_path("full.lock") } 133 | 134 | it "returns success and updates file" do 135 | expect(gradual_cli).to eq(0) 136 | expect(actual_data).to eq(expected_data) 137 | expect($stdout.string).to include("Uh oh, RuboCop Gradual got worse:") 138 | .and include("app/controllers/books_controller.rb (2 new issues)") 139 | .and include("Force updating lock file...") 140 | end 141 | end 142 | end 143 | 144 | context "when the lock file become better" do 145 | let(:actual_lock_path) { File.expand_path("was_worse.lock") } 146 | let(:expected_lock_path) { File.expand_path("full.lock") } 147 | 148 | it "updates file" do 149 | expect(gradual_cli).to eq(0) 150 | expect(actual_data).to eq(expected_data) 151 | expect($stdout.string).to include("RuboCop Gradual got 2 issue(s) fixed, 22 left. Keep going!") 152 | end 153 | 154 | include_examples "error with --check option", with_extended_steps: false 155 | end 156 | 157 | context "when no issues found" do 158 | let(:options) { %w[--config security_only_rubocop.yml --gradual-file] } 159 | let(:actual_lock_path) { File.expand_path("full.lock") } 160 | 161 | it "removes file" do 162 | expect(gradual_cli).to eq(0) 163 | expect(File.exist?(actual_lock_path)).to be(false) 164 | expect($stdout.string).to include("RuboCop Gradual is complete!") 165 | end 166 | 167 | include_examples "error with --check option", with_extended_steps: false 168 | end 169 | 170 | context "when no lock file and no issues found" do 171 | let(:options) { %w[--config security_only_rubocop.yml --gradual-file] } 172 | let(:actual_lock_path) { File.expand_path("no.lock") } 173 | 174 | it "removes file" do 175 | expect(gradual_cli).to eq(0) 176 | expect($stdout.string).to include("RuboCop Gradual is complete!") 177 | end 178 | 179 | context "with --check option" do 180 | let(:options) { super().unshift("--check") } 181 | 182 | it "returns success and doesn't update file" do 183 | expect(gradual_cli).to eq(0) 184 | expect($stdout.string).to include("RuboCop Gradual is complete!") 185 | end 186 | end 187 | end 188 | 189 | context "with --autocorrect option" do 190 | let(:options) { %w[--autocorrect --gradual-file] } 191 | let(:actual_lock_path) { File.expand_path("full.lock") } 192 | let(:expected_lock_path) { File.expand_path("autocorrected.lock") } 193 | 194 | it "updates file" do 195 | expect(gradual_cli).to eq(0) 196 | expect(actual_data).to eq(expected_data) 197 | expect($stdout.string).to include("Inspecting 3 file(s) for autocorrection...") 198 | .and include("Fixed 2 file(s).") 199 | .and include("RuboCop Gradual got 13 issue(s) fixed, 9 left. Keep going!") 200 | end 201 | end 202 | 203 | context "with --list option" do 204 | let(:options) { %w[--list --gradual-file] } 205 | 206 | it "lists project files" do 207 | expect(gradual_cli).to eq(1) 208 | expect($stdout.string.split("\n")).to match_array(Dir.glob("**/*.rb")) 209 | end 210 | 211 | context "with --autocorrect option" do 212 | let(:options) { %w[--list --autocorrect --gradual-file] } 213 | 214 | it "lists project files" do 215 | expect(gradual_cli).to eq(1) 216 | expect($stdout.string.split("\n")).to match_array(Dir.glob("**/*.rb")) 217 | end 218 | end 219 | 220 | context "with --autocorrect option without changes" do 221 | let(:options) { %w[--list --autocorrect --gradual-file] } 222 | let(:actual_lock_path) { File.expand_path("full.lock") } 223 | 224 | it "lists project files" do 225 | expect(gradual_cli).to eq(1) 226 | expect($stdout.string).to eq("") 227 | end 228 | end 229 | 230 | context "with --autocorrect option and outdated lock file" do 231 | let(:options) { %w[--list --autocorrect --gradual-file] } 232 | let(:actual_lock_path) { File.expand_path("outdated.lock") } 233 | 234 | it "lists project files" do 235 | expect(gradual_cli).to eq(1) 236 | expect($stdout.string).to eq("app/controllers/books_controller.rb\n") 237 | end 238 | end 239 | end 240 | end 241 | --------------------------------------------------------------------------------