├── .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 | [](https://rubygems.org/gems/rubocop-gradual)
4 | [](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 |
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 |
--------------------------------------------------------------------------------