rubocop_director 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9495c42ab7429abd8ad0cf4c31074079fba3419b9cccc56a9ab51fdd66ae3c49
4
+ data.tar.gz: e634bc7e5f50acedb08be3c5dbb2f2e8ea03cbda5ef9694f89e87c5943b16a8d
5
+ SHA512:
6
+ metadata.gz: 5ad6d7874bae6bc6bab004da66c96b777a24bec8f50457a7dc46aabb7b5e349bc49d52d2866e4f06c14c2ad71e9840d03c9fcc088d1d237061f80acfef3735f8
7
+ data.tar.gz: 8b1aeda0559907b4696f47b77a069f605816e7d25b99318bb36e54288511c859350d144ddc8469c40e8c347afb0c2c16cbd0cd956d02200974703b9decbb04f4
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ require:
2
+ - standard
3
+
4
+ inherit_gem:
5
+ standard: config/base.yml
6
+
7
+ AllCops:
8
+ TargetRubyVersion: 3.2
data/.standard.yml ADDED
@@ -0,0 +1 @@
1
+ ruby_version: 3.2
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-05-08
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in rubocop_director.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "rspec", "~> 3.0"
11
+
12
+ gem "standard", "~> 1.3"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 DmitryTsepelev
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.
data/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # RubocopDirector
2
+
3
+ A command–line utility for refactoring planning. It uses `.rubocop_todo.yml` and git history to prioritize a list of refactorings that can bring the most value.
4
+
5
+ ## Installation
6
+
7
+ Prerequisites:
8
+
9
+ - `sed`;
10
+ - `git` repo;
11
+ - generated `.rubocop_todo.yml`.
12
+
13
+ Install the gem and add to the application's Gemfile by executing:
14
+
15
+ ```bash
16
+ $ bundle add rubocop_director
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ First of all, create the initial config file based on `.rubocop_todo.yml`:
22
+
23
+ ```bash
24
+ bundle exec rubocop-director --generate-config
25
+ ```
26
+
27
+ Optionally adjust weights in the config:
28
+
29
+ 1. `update_weight` means how important the fact that file was recently updated;
30
+ 2. `default_cop_weight` will be used in case when weight for a specific cop is not set;
31
+ 3. `weights` contains weights for specific cops.
32
+
33
+ _You can use any numbers you want, but I think it's better to stick with something from 0 to 1._
34
+
35
+ Build the report:
36
+
37
+ ```bash
38
+ bundle exec rubocop-director
39
+ ```
40
+
41
+ As a result you'll get something like this:
42
+
43
+ ```bash
44
+ đź’ˇ Checking git history since 1995-01-01 to find hot files...
45
+ 💡🎥 Running rubocop to get the list of offences to fix...
46
+ 💡🎥🎬 Calculating a list of files to refactor...
47
+ --------------------
48
+ spec/models/user.rb
49
+ updated 10 times since -4712-01-01
50
+ offences: RSpec/AroundBlock - 8
51
+ refactoring value: 110 (55%)
52
+ --------------------
53
+ spec/models/order.rb
54
+ updated 20 times since -4712-01-01
55
+ offences: Rspec/BeEql - 4
56
+ refactoring value: 90 (45%)
57
+ ```
58
+
59
+ > Want a different output format (e.g., CSV)? Let me know, open an issue!
60
+
61
+ Value is calculated using a formula: `sum of value from each cop (<number of offences> * <cop weight> * <number of file updates> * <update weight>)`.
62
+
63
+ If you need to count updates from a specific date—use `--since`:
64
+
65
+ ```bash
66
+ bundle exec rubocop-director --since=2023-01-01
67
+ ```
68
+
69
+ ## Development
70
+
71
+ After checking out the repo, run `bundle insatll` to install dependencies
72
+
73
+ ## Contributing
74
+
75
+ Bug reports and pull requests are welcome on GitHub at https://github.com/DmitryTsepelev/rubocop_director.
76
+
77
+ ## License
78
+
79
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
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 "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "rubocop_director"
4
+
5
+ RubocopDirector::Runner.new(ARGV).perform
@@ -0,0 +1,37 @@
1
+ require "dry/monads"
2
+
3
+ module RubocopDirector
4
+ module Commands
5
+ class GenerateConfig
6
+ include Dry::Monads[:result]
7
+ include Dry::Monads::Do.for(:run)
8
+
9
+ RUBOCOP_TODO = ".rubocop_todo.yml"
10
+
11
+ def run
12
+ todo = yield load_config
13
+
14
+ weights = todo.keys.each_with_object({}).each do |cop, acc|
15
+ acc.merge!(cop => 1)
16
+ end
17
+
18
+ # TODO: warn if file exists
19
+ File.write(".rubocop-director.yml", {
20
+ "update_weight" => 1,
21
+ "default_cop_weight" => 1,
22
+ "weights" => weights
23
+ }.to_yaml)
24
+
25
+ Success("Config generated")
26
+ end
27
+
28
+ private
29
+
30
+ def load_config
31
+ Success(YAML.load_file(RUBOCOP_TODO))
32
+ rescue Errno::ENOENT
33
+ Failure("#{RUBOCOP_TODO} not found, generate it using `rubocop --regenerate-todo`")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,54 @@
1
+ require "open3"
2
+ require "json"
3
+ require "yaml"
4
+ require "date"
5
+
6
+ require "rubocop_director/rubocop_stats"
7
+ require "rubocop_director/git_log_stats"
8
+ require "rubocop_director/file_stats_builder"
9
+ require "rubocop_director/output_formatter"
10
+
11
+ module RubocopDirector
12
+ module Commands
13
+ class Plan
14
+ include Dry::Monads[:result]
15
+ include Dry::Monads::Do.for(:run)
16
+
17
+ def initialize(since)
18
+ @since = since || "1995-01-01"
19
+ end
20
+
21
+ def run
22
+ config = yield load_config
23
+ update_counts = yield load_git_stats
24
+ rubocop_json = yield load_rubocop_json
25
+ ranged_files = yield range_files(rubocop_json: rubocop_json, update_counts: update_counts, config: config)
26
+
27
+ OutputFormatter.new(ranged_files: ranged_files, since: @since).call
28
+ end
29
+
30
+ private
31
+
32
+ def load_config
33
+ Success(YAML.load_file(CONFIG_NAME))
34
+ rescue Errno::ENOENT
35
+ Failure("#{CONFIG_NAME} not found, generate it using `rubocop-director --generate-config`")
36
+ end
37
+
38
+ def load_git_stats
39
+ puts "đź’ˇ Checking git history since #{@since} to find hot files..."
40
+ GitLogStats.new(@since).fetch
41
+ end
42
+
43
+ def load_rubocop_json
44
+ puts "💡🎥 Running rubocop to get the list of offences to fix..."
45
+ RubocopStats.new.fetch
46
+ end
47
+
48
+ def range_files(rubocop_json:, update_counts:, config:)
49
+ puts "💡🎥🎬 Calculating a list of files to refactor..."
50
+ RubocopDirector::FileStatsBuilder.new(rubocop_json: rubocop_json, update_counts: update_counts, config: config).build
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
1
+ module RubocopDirector
2
+ class FileStatsBuilder
3
+ include Dry::Monads[:result]
4
+ include Dry::Monads::Do.for(:build, :find_refactoring_value)
5
+
6
+ def initialize(rubocop_json:, update_counts:, config:)
7
+ @rubocop_json = rubocop_json
8
+ @update_counts = update_counts
9
+ @config = config
10
+ end
11
+
12
+ def build
13
+ file_stats = files_with_offenses.map do |file|
14
+ stats = {
15
+ path: file["path"],
16
+ updates_count: update_counts[file["path"]] || 0,
17
+ offense_counts: file["offenses"].group_by { |offense| offense["cop_name"] }.transform_values(&:count)
18
+ }
19
+
20
+ stats[:value] = yield find_refactoring_value(stats)
21
+
22
+ stats
23
+ end
24
+
25
+ Success(file_stats.sort_by { _1[:value] }.reverse)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :rubocop_json, :update_counts, :config
31
+
32
+ def files_with_offenses = rubocop_json.select { |file| file["offenses"].any? }
33
+
34
+ def find_refactoring_value(file)
35
+ update_weight = yield fetch_update_weight
36
+
37
+ offence_sum = file[:offense_counts].sum do |cop_name, count|
38
+ cop_weight = yield fetch_cop_weight(cop_name)
39
+ cop_weight * count
40
+ end
41
+
42
+ Success((offence_sum * file[:updates_count] * update_weight).to_f)
43
+ end
44
+
45
+ def fetch_cop_weight(cop_name)
46
+ weight = config.dig("weights", cop_name) || config["default_cop_weight"]
47
+
48
+ if weight
49
+ Success(weight)
50
+ else
51
+ Failure("could not find weight for #{cop_name} and `default_cop_weight` is not configured")
52
+ end
53
+ end
54
+
55
+ def fetch_update_weight
56
+ weight = config["update_weight"]
57
+
58
+ if weight
59
+ Success(weight)
60
+ else
61
+ Failure("`update_weight` is not configured")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,28 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "yaml"
4
+
5
+ require "dry/monads"
6
+
7
+ module RubocopDirector
8
+ class GitLogStats
9
+ include Dry::Monads[:result]
10
+
11
+ def initialize(since)
12
+ @since = since
13
+ end
14
+
15
+ def fetch
16
+ stdout, stderr = Open3.capture3("git log --since=\"#{@since}\" --pretty=format: --name-only | sort | uniq -c | sort -rg")
17
+
18
+ return Failure("Failed to fetch git stats: #{stderr}") if stderr.length > 0
19
+
20
+ stats = stdout.split("\n")[1..].each_with_object({}) do |line, acc|
21
+ number, path = line.split
22
+ acc[path] = number.to_i
23
+ end
24
+
25
+ Success(stats)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "yaml"
4
+
5
+ require "dry/monads"
6
+
7
+ module RubocopDirector
8
+ class OutputFormatter
9
+ include Dry::Monads[:result]
10
+
11
+ def initialize(ranged_files:, since:)
12
+ @ranged_files = ranged_files
13
+ @since = since
14
+ end
15
+
16
+ def call
17
+ result = @ranged_files.each_with_object([]) do |file, result|
18
+ result << "-" * 20
19
+ result << file[:path]
20
+ result << "updated #{file[:updates_count]} times since #{@since}"
21
+ result << "offences: #{file[:offense_counts].map { |cop, count| "#{cop} - #{count}" }.join(", ")}"
22
+ result << "refactoring value: #{file[:value]} (#{(100 * file[:value] / total_value.to_f).round(5)}%)"
23
+ end
24
+
25
+ Success(result)
26
+ end
27
+
28
+ private
29
+
30
+ def total_value = @ranged_files.sum { _1[:value] }
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ require "open3"
2
+ require "json"
3
+ require "yaml"
4
+
5
+ require "dry/monads"
6
+
7
+ module RubocopDirector
8
+ class RubocopStats
9
+ include Dry::Monads[:result]
10
+
11
+ def fetch
12
+ _, stderr = Open3.capture3("sed '/todo/d' ./.rubocop.yml > tmpfile; mv tmpfile ./.rubocop.yml")
13
+ if stderr.length > 0
14
+ return Failure("Failed to remove TODO from rubocop config: #{stderr}")
15
+ end
16
+
17
+ stdout, stderr = Open3.capture3("bundle exec rubocop --format json")
18
+
19
+ if stderr.length > 0
20
+ Failure("Failed to fetch rubocop stats: #{stderr}")
21
+ else
22
+ Success(JSON.parse(stdout)["files"])
23
+ end
24
+ ensure
25
+ Open3.capture3("git checkout ./.rubocop.yml")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ require "optparse"
2
+
3
+ require_relative "commands/generate_config"
4
+ require_relative "commands/plan"
5
+
6
+ module RubocopDirector
7
+ class Runner
8
+ def initialize(args)
9
+ arg_parser.parse(args)
10
+ @command ||= Commands::Plan.new(@since)
11
+ end
12
+
13
+ def perform
14
+ @command.run.either(
15
+ ->(success_message) { puts success_message },
16
+ ->(failure_message) { puts "\nFailure: #{failure_message}" }
17
+ )
18
+ end
19
+
20
+ private
21
+
22
+ def arg_parser
23
+ OptionParser.new do |p|
24
+ p.on("--generate-config", "Generate default config based on .rubocop_todo.yml") do |since|
25
+ @command = Commands::GenerateConfig.new
26
+ end
27
+
28
+ p.on("--since=SINCE", "Specify date to start checking git history") do |since|
29
+ @since = since
30
+ end
31
+
32
+ p.on("--help", "Prints this help") do
33
+ puts p
34
+ exit
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubocopDirector
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,8 @@
1
+ require "rubocop_director/commands/generate_config"
2
+ require "rubocop_director/commands/plan"
3
+ require "rubocop_director/version"
4
+ require "rubocop_director/runner"
5
+
6
+ module RubocopDirector
7
+ CONFIG_NAME = ".rubocop-director.yml"
8
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop_director
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DmitryTsepelev
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-05-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-monads
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: '1.6'
27
+ description: Plan your refactorings properly.
28
+ email:
29
+ - dmitry.a.tsepelev@gmail.com
30
+ executables:
31
+ - rubocop-director
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".rspec"
36
+ - ".rubocop.yml"
37
+ - ".standard.yml"
38
+ - CHANGELOG.md
39
+ - Gemfile
40
+ - LICENSE.txt
41
+ - README.md
42
+ - Rakefile
43
+ - exe/rubocop-director
44
+ - lib/rubocop_director.rb
45
+ - lib/rubocop_director/commands/generate_config.rb
46
+ - lib/rubocop_director/commands/plan.rb
47
+ - lib/rubocop_director/file_stats_builder.rb
48
+ - lib/rubocop_director/git_log_stats.rb
49
+ - lib/rubocop_director/output_formatter.rb
50
+ - lib/rubocop_director/rubocop_stats.rb
51
+ - lib/rubocop_director/runner.rb
52
+ - lib/rubocop_director/version.rb
53
+ homepage: https://github.com/DmitryTsepelev/rubocop_director
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ homepage_uri: https://github.com/DmitryTsepelev/rubocop_director
58
+ source_code_uri: https://github.com/DmitryTsepelev/rubocop_director
59
+ changelog_uri: https://github.com/DmitryTsepelev/rubocop_director/blob/master/CHANGELOG.md
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.0.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.2.15
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: Plan your refactorings properly.
79
+ test_files: []