rubocounsel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # RuboCounsel
2
+
3
+ <img src="rubocounsel.png" alt="RuboCounsel" width="400">
4
+
5
+ An interactive CLI tool that transforms the RuboCop experience from "fix these errors" to "let's decide on your team's style together." Instead of dumping a wall of offenses, RuboCounsel walks you through each failing cop, explains what it does, shows real code examples, and helps you make informed decisions that get written to `.rubocop.yml`.
6
+
7
+ The result: a RuboCop configuration that reflects conscious choices, not cargo-culted defaults.
8
+
9
+ <img src="example.png" alt="RuboCounsel example" width="600">
10
+
11
+ ## Installation
12
+
13
+ Add the gem to your application's Gemfile:
14
+
15
+ ```bash
16
+ bundle add rubocounsel
17
+ ```
18
+
19
+ Or install it directly:
20
+
21
+ ```bash
22
+ gem install rubocounsel
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Run RuboCounsel against your project:
28
+
29
+ ```bash
30
+ rubocounsel
31
+ ```
32
+
33
+ Or specify paths:
34
+
35
+ ```bash
36
+ rubocounsel app/ lib/
37
+ ```
38
+
39
+ Any RuboCop flags are passed through, so you can filter to specific cops or use a custom config:
40
+
41
+ ```bash
42
+ rubocounsel --only Style/StringLiterals
43
+ rubocounsel --config .rubocop_strict.yml app/
44
+ ```
45
+
46
+ RuboCounsel will:
47
+
48
+ 1. Run RuboCop and identify which cops have offenses
49
+ 2. Skip any cops you've already configured in `.rubocop.yml`
50
+ 3. Walk you through each remaining cop interactively
51
+
52
+ For each cop, you'll see its documentation and a link to the full docs, then choose to:
53
+
54
+ - **Configure** — set the cop's attributes (style, max length, etc.) via guided prompts
55
+ - **Disable** — add `Enabled: false` to your config
56
+ - **Skip** — leave it unconfigured for now
57
+
58
+ Configuration choices are saved to `.rubocop.yml` after each cop, so you won't lose progress if you quit early. Re-running RuboCounsel will pick up where you left off, skipping cops that are already configured.
59
+
60
+ RuboCounsel supports extension gems like `rubocop-rails`, `rubocop-rspec`, and `rubocop-performance` — any cops registered in your bundle will be included automatically.
61
+
62
+ ## Development
63
+
64
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
65
+
66
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
67
+
68
+ ## Contributing
69
+
70
+ Bug reports and pull requests are welcome on GitHub at https://github.com/dewyze/rubocounsel. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/dewyze/rubocounsel/blob/main/CODE_OF_CONDUCT.md).
71
+
72
+ ## License
73
+
74
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
75
+
76
+ ## Code of Conduct
77
+
78
+ Everyone interacting in the RuboCounsel project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/dewyze/rubocounsel/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
data/example.png ADDED
Binary file
data/exe/rubocounsel ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubocounsel"
5
+
6
+ exit RuboCounsel::CLI.run(ARGV)
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cli/ui"
4
+
5
+ module RuboCounsel
6
+ # Command-line interface that ties all components together.
7
+ #
8
+ # Responsibilities:
9
+ # - Run the Runner to collect offending cops
10
+ # - Pass offending cop names to the Counselor
11
+ # - Pass Counselor's choices to ConfigWriter
12
+ # - Output the generated config
13
+ class CLI
14
+ HELP = <<~TEXT
15
+ Usage: rubocounsel [options] [paths]
16
+
17
+ RuboCounsel walks you through each offending RuboCop cop interactively,
18
+ helping you configure, disable, or skip each one.
19
+
20
+ All RuboCop flags are passed through (e.g. --only, --except, --config).
21
+
22
+ Options:
23
+ -h, --help Show this help message
24
+ -v, --version Show version
25
+ TEXT
26
+
27
+ def self.run(args, output: $stdout)
28
+ if args.include?("--help") || args.include?("-h")
29
+ output.puts HELP
30
+ return 0
31
+ end
32
+
33
+ if args.include?("--version") || args.include?("-v")
34
+ output.puts "rubocounsel #{RuboCounsel::VERSION}"
35
+ return 0
36
+ end
37
+
38
+ options, paths = RuboCop::Options.new.parse(args)
39
+ paths = ["."] if paths.empty?
40
+
41
+ new(paths: paths, rubocop_options: options, output: output).run
42
+ end
43
+
44
+ def initialize(paths: ["."], rubocop_options: {}, counselor_factory: nil, output: $stdout)
45
+ @paths = paths
46
+ @rubocop_options = rubocop_options
47
+ @counselor_factory = counselor_factory || method(:default_counselor)
48
+ @output = output
49
+ end
50
+
51
+ def run
52
+ ::CLI::UI::StdoutRouter.enable
53
+
54
+ cop_names = collect_offending_cops
55
+ return 0 if cop_names.empty?
56
+
57
+ all_choices = {}
58
+ counsel_cops(cop_names) do |cop_name, choice|
59
+ all_choices[cop_name] = choice
60
+ output_config(all_choices)
61
+ end
62
+
63
+ if all_choices.empty?
64
+ @output.puts "All offending cops are already configured."
65
+ end
66
+
67
+ 0
68
+ rescue Interrupt
69
+ @output.puts "\nExiting..."
70
+ 0
71
+ end
72
+
73
+ private
74
+
75
+ def collect_offending_cops
76
+ runner = Runner.new(rubocop_options: @rubocop_options)
77
+ runner.run(@paths)
78
+ runner.offending_cops.to_a
79
+ end
80
+
81
+ def counsel_cops(cop_names, &block)
82
+ counselor = @counselor_factory.call(cop_names)
83
+ counselor.counsel(&block)
84
+ end
85
+
86
+ def default_counselor(cop_names)
87
+ Counselor.new(cop_names)
88
+ end
89
+
90
+ def output_config(choices)
91
+ ConfigWriter.new(choices).merge_and_write(".rubocop.yml")
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "active_support/core_ext/hash/deep_merge"
6
+
7
+ module RuboCounsel
8
+ # Generates .rubocop.yml content from user choices.
9
+ #
10
+ # Converts a hash of cop choices to YAML format and optionally writes to file.
11
+ # The choices hash maps cop names to their configuration settings:
12
+ #
13
+ # {
14
+ # "Style/StringLiterals" => { "EnforcedStyle" => "double_quotes" },
15
+ # "Metrics/MethodLength" => { "Max" => 15 }
16
+ # }
17
+ class ConfigWriter
18
+ PRIORITY_KEYS = ["inherit_from", "inherit_mode", "require", "AllCops"].freeze
19
+
20
+ attr_reader :choices
21
+
22
+ def initialize(choices)
23
+ @choices = choices
24
+ end
25
+
26
+ def to_yaml
27
+ return "" if choices.empty?
28
+
29
+ add_blank_lines(sort_config(choices).to_yaml)
30
+ end
31
+
32
+ def write(path)
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+ File.write(path, to_yaml)
35
+ end
36
+
37
+ # Merges choices into an existing config file and writes it back.
38
+ def merge_and_write(path)
39
+ existing = File.exist?(path) ? YAML.load_file(path) || {} : {}
40
+ merged = sort_config(existing.deep_merge(choices))
41
+ File.write(path, add_blank_lines(merged.to_yaml))
42
+ end
43
+
44
+ private
45
+
46
+ def sort_config(config)
47
+ priority = {}
48
+ cops = {}
49
+
50
+ config.each do |key, value|
51
+ if PRIORITY_KEYS.include?(key)
52
+ priority[key] = value
53
+ else
54
+ cops[key] = value
55
+ end
56
+ end
57
+
58
+ ordered = PRIORITY_KEYS.each_with_object({}) do |key, hash|
59
+ hash[key] = priority[key] if priority.key?(key)
60
+ end
61
+
62
+ ordered.merge(cops.sort.to_h)
63
+ end
64
+
65
+ def add_blank_lines(yaml)
66
+ yaml.gsub(/^([A-Za-z])/, "\n\\1").lstrip
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCounsel
4
+ # Represents one configurable aspect of a cop (e.g., EnforcedStyle, Max).
5
+ #
6
+ # Determines the attribute type based on its value:
7
+ # - :choice - has a list of valid options (EnforcedStyle with SupportedStyles)
8
+ # - :boolean - true/false value
9
+ # - :integer - numeric value
10
+ # - :array - list of values (AllowedMethods, etc.)
11
+ # - :string - any other string value
12
+ class ConfigurableAttribute
13
+ attr_reader :name, :default_value, :options
14
+
15
+ def initialize(name, default_value, options: nil)
16
+ @name = name
17
+ @default_value = default_value
18
+ @options = options
19
+ end
20
+
21
+ def type
22
+ return :choice if @options
23
+
24
+ case @default_value
25
+ when true, false
26
+ :boolean
27
+ when Integer
28
+ :integer
29
+ when Array
30
+ :array
31
+ else
32
+ :string
33
+ end
34
+ end
35
+
36
+ def choice?
37
+ type == :choice
38
+ end
39
+
40
+ def boolean?
41
+ type == :boolean
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/inclusion"
4
+ require "rubocop"
5
+
6
+ module RuboCounsel
7
+ # Central repository of cop information.
8
+ # Uses RuboCop's Registry for cop discovery and ExampleLoader for documentation.
9
+ #
10
+ # The Registry is RuboCop's authoritative cop list - stable and complete.
11
+ # Examples come from YARD parsing (separate concern, can fail gracefully).
12
+ class CopCatalog
13
+ # InternalAffairs cops are for RuboCop development, not end users.
14
+ EXCLUDED_COP_DEPARTMENTS = ["InternalAffairs"].freeze
15
+
16
+ def initialize(example_loader: ExampleLoader.instance)
17
+ @example_loader = example_loader
18
+ @cops = nil
19
+ @examples_by_class_name = nil
20
+ end
21
+
22
+ # Returns a Hash where keys are cop names (e.g., "Style/StringLiterals")
23
+ # and values are cop classes.
24
+ def cops
25
+ @cops ||= load_cops
26
+ end
27
+
28
+ # Returns the cop class for the given name, or nil if not found.
29
+ def lookup(cop_name)
30
+ cops[cop_name]
31
+ end
32
+
33
+ # Returns an Array of Example objects for the given cop name.
34
+ # Returns empty array if no examples found.
35
+ def examples_for(cop_name)
36
+ cop_class = lookup(cop_name)
37
+ return [] unless cop_class
38
+
39
+ examples_by_class_name[cop_class.to_s] || []
40
+ end
41
+
42
+ # Returns the full YARD docstring for the given cop name.
43
+ # Returns nil if no docstring found.
44
+ def docstring_for(cop_name)
45
+ cop_class = lookup(cop_name)
46
+ return nil unless cop_class
47
+
48
+ docstrings_by_class_name[cop_class.to_s]
49
+ end
50
+
51
+ private
52
+
53
+ def load_cops
54
+ result = {}
55
+
56
+ RuboCop::Cop::Registry.global.each do |cop_class|
57
+ next if cop_class.department.to_s.in?(EXCLUDED_COP_DEPARTMENTS)
58
+
59
+ result[cop_class.cop_name] = cop_class
60
+ end
61
+
62
+ result
63
+ end
64
+
65
+ def examples_by_class_name
66
+ @examples_by_class_name ||= @example_loader.examples
67
+ end
68
+
69
+ def docstrings_by_class_name
70
+ @docstrings_by_class_name ||= @example_loader.docstrings
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module RuboCounsel
6
+ # Represents a single cop with its configuration and examples.
7
+ #
8
+ # This is an entry in the CopCatalog - a data object that combines:
9
+ # - The cop class (from Registry)
10
+ # - Its configuration (from RuboCop's default config)
11
+ # - Its examples (from YARD parsing)
12
+ # - Computed configurable attributes
13
+ class CopEntry
14
+ # Keys in cop configuration that are metadata, not user-configurable options.
15
+ METADATA_KEYS = [
16
+ "Description",
17
+ "StyleGuide",
18
+ "Enabled",
19
+ "VersionAdded",
20
+ "VersionChanged",
21
+ "VersionRemoved",
22
+ "Reference",
23
+ "Safe",
24
+ "SafeAutoCorrect"
25
+ ].freeze
26
+
27
+ attr_reader :cop_class, :examples
28
+
29
+ def initialize(cop_class, examples: [])
30
+ @cop_class = cop_class
31
+ @examples = examples
32
+ end
33
+
34
+ def cop_name
35
+ @cop_class.cop_name
36
+ end
37
+
38
+ def configuration
39
+ @configuration ||= RuboCop::ConfigLoader.default_configuration[cop_name].to_h
40
+ end
41
+
42
+ def configurable_attributes
43
+ @configurable_attributes ||= build_configurable_attributes
44
+ end
45
+
46
+ private
47
+
48
+ def build_configurable_attributes
49
+ attributes = []
50
+
51
+ configuration.each do |key, value|
52
+ next if metadata_key?(key)
53
+ next if supported_key?(key)
54
+
55
+ options = find_options_for(key)
56
+ attributes << ConfigurableAttribute.new(key, value, options: options)
57
+ end
58
+
59
+ attributes
60
+ end
61
+
62
+ def metadata_key?(key)
63
+ METADATA_KEYS.include?(key)
64
+ end
65
+
66
+ def supported_key?(key)
67
+ key.start_with?("Supported")
68
+ end
69
+
70
+ # Finds the corresponding SupportedXxx options for an EnforcedXxx key.
71
+ # E.g., EnforcedStyle -> SupportedStyles
72
+ def find_options_for(key)
73
+ return nil unless key.start_with?("Enforced")
74
+
75
+ # EnforcedStyle -> SupportedStyles, EnforcedHashRocketStyle -> SupportedHashRocketStyles
76
+ supported_key = key.sub("Enforced", "Supported") + "s"
77
+ configuration[supported_key]
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cli/ui"
4
+ require "rubocop"
5
+
6
+ module RuboCounsel
7
+ # The interactive CLI experience for walking through offending cops.
8
+ #
9
+ # Responsibilities:
10
+ # - Walk through each offending cop
11
+ # - Display cop information and examples
12
+ # - Collect user choices via cli-ui prompts
13
+ # - Return choices hash for ConfigWriter
14
+ #
15
+ # Cops that are already configured in .rubocop.yml are skipped by default.
16
+ class Counselor
17
+ ACTIONS = ["Configure", "Disable", "Skip"].freeze
18
+ FILE_TARGETING_ATTRIBUTES = ["Include", "Exclude"].freeze
19
+
20
+ def initialize(cop_names, catalog: CopCatalog.new, user_config: nil, output: $stdout)
21
+ @cop_names = cop_names
22
+ @catalog = catalog
23
+ @user_config_override = user_config
24
+ @output = output
25
+ end
26
+
27
+ def counsel
28
+ choices = {}
29
+
30
+ @cop_names.each do |cop_name|
31
+ choice = counsel_cop(cop_name)
32
+ if choice
33
+ choices[cop_name] = choice
34
+ yield(cop_name, choice) if block_given?
35
+ end
36
+ end
37
+
38
+ choices
39
+ end
40
+
41
+ private
42
+
43
+ def counsel_cop(cop_name)
44
+ return nil if already_configured?(cop_name)
45
+
46
+ entry = build_cop_entry(cop_name)
47
+ return nil unless entry
48
+
49
+ docstring = @catalog.docstring_for(cop_name)
50
+
51
+ ::CLI::UI::Frame.open(entry.cop_name, color: :blue) do
52
+ display_cop_info(entry, docstring)
53
+ action = ::CLI::UI::Prompt.ask("What would you like to do?", options: ACTIONS)
54
+
55
+ case action
56
+ when "Disable"
57
+ { "Enabled" => false }
58
+ when "Skip"
59
+ nil
60
+ when "Configure"
61
+ configure_cop(entry)
62
+ end
63
+ end
64
+ end
65
+
66
+ def already_configured?(cop_name)
67
+ user_config.key?(cop_name)
68
+ end
69
+
70
+ def user_config
71
+ @user_config ||= @user_config_override || load_user_config
72
+ end
73
+
74
+ def load_user_config
75
+ return {} unless File.exist?(".rubocop.yml")
76
+
77
+ RuboCop::ConfigLoader.load_file(".rubocop.yml")
78
+ end
79
+
80
+ def build_cop_entry(cop_name)
81
+ cop_class = @catalog.lookup(cop_name)
82
+ return nil unless cop_class
83
+
84
+ examples = @catalog.examples_for(cop_name)
85
+ CopEntry.new(cop_class, examples: examples)
86
+ end
87
+
88
+ def display_cop_info(entry, docstring)
89
+ # Prefer full YARD docstring over short config description
90
+ description = docstring || entry.configuration["Description"]
91
+ @output.puts ::CLI::UI.fmt("{{cyan:#{description}}}\n") if description
92
+
93
+ # Show documentation URL
94
+ doc_url = entry.cop_class.documentation_url
95
+ @output.puts ::CLI::UI.fmt("{{underline:#{doc_url}}}\n") if doc_url
96
+ end
97
+
98
+ def configure_cop(entry)
99
+ config = {}
100
+
101
+ display_array_attributes_message(entry.configurable_attributes)
102
+
103
+ entry.configurable_attributes.each do |attr|
104
+ value = prompt_for_attribute(attr, entry.examples)
105
+ config[attr.name] = value unless value.nil?
106
+ end
107
+
108
+ config
109
+ end
110
+
111
+ def display_array_attributes_message(attributes)
112
+ array_attrs = attributes
113
+ .select { |attr| attr.type == :array }
114
+ .reject { |attr| FILE_TARGETING_ATTRIBUTES.include?(attr.name) }
115
+
116
+ return if array_attrs.empty?
117
+
118
+ names = array_attrs.map(&:name).join(", ")
119
+ @output.puts ::CLI::UI.fmt(
120
+ "{{green:These attributes accept lists and can be configured directly in .rubocop.yml: #{names}}}"
121
+ )
122
+ end
123
+
124
+ def prompt_for_attribute(attr, examples)
125
+ if attr.type.in?([:choice, :boolean]) && !has_examples_for?(attr.name, examples)
126
+ display_no_examples_message(attr.name)
127
+ end
128
+
129
+ case attr.type
130
+ when :choice
131
+ prompt_for_choice(attr, examples)
132
+ when :boolean
133
+ prompt_for_boolean(attr, examples)
134
+ when :array
135
+ # Skip array attributes (Include, Exclude, AllowedMethods, etc.)
136
+ # These aren't style choices and prompting for them causes type corruption
137
+ nil
138
+ else
139
+ prompt_for_value(attr)
140
+ end
141
+ end
142
+
143
+ def prompt_for_choice(attr, examples)
144
+ # Put default first, then the rest
145
+ default_val = attr.default_value.to_s
146
+ sorted_options = attr.options.sort_by { |opt| opt == default_val ? 0 : 1 }
147
+ options_with_default = sorted_options.map do |opt|
148
+ opt == default_val ? "#{opt} (default)" : opt
149
+ end
150
+
151
+ loop do
152
+ options = has_examples_for?(attr.name, examples) ? options_with_default + ["Show examples"] : options_with_default
153
+
154
+ answer = ::CLI::UI::Prompt.ask("#{attr.name}?", options: options)
155
+
156
+ if answer == "Show examples"
157
+ display_examples_for_attribute(attr.name, examples)
158
+ else
159
+ # Strip "(default)" suffix if present
160
+ return answer.sub(/ \(default\)$/, "")
161
+ end
162
+ end
163
+ end
164
+
165
+ def has_examples_for?(attr_name, examples)
166
+ examples.any? { |ex| ex.attribute == attr_name }
167
+ end
168
+
169
+ def prompt_for_boolean(attr, examples)
170
+ # Always show default first
171
+ base_options = if attr.default_value
172
+ ["yes (default)", "no"]
173
+ else
174
+ ["no (default)", "yes"]
175
+ end
176
+
177
+ loop do
178
+ options = has_examples_for?(attr.name, examples) ? base_options + ["Show examples"] : base_options
179
+
180
+ answer = ::CLI::UI::Prompt.ask("#{attr.name}?", options: options)
181
+
182
+ if answer == "Show examples"
183
+ display_examples_for_attribute(attr.name, examples)
184
+ else
185
+ return answer.sub(/ \(default\)$/, "") == "yes"
186
+ end
187
+ end
188
+ end
189
+
190
+ def prompt_for_value(attr)
191
+ ::CLI::UI::Prompt.ask(
192
+ "#{attr.name}? (default: #{attr.default_value})",
193
+ default: attr.default_value.to_s
194
+ )
195
+ end
196
+
197
+ def display_no_examples_message(attr_name)
198
+ @output.puts ::CLI::UI.fmt("{{italic:No examples available for #{attr_name}.}}")
199
+ end
200
+
201
+ def display_examples_for_attribute(attr_name, examples)
202
+ relevant = examples.select { |ex| ex.attribute == attr_name }
203
+ return if relevant.empty?
204
+
205
+ relevant.each do |example|
206
+ label = example.default? ? "#{example.value} (default)" : example.value
207
+ @output.puts <<~OUTPUT
208
+ #{::CLI::UI.fmt("{{yellow:#{label}:}}")}
209
+ #{example.text}
210
+ OUTPUT
211
+ end
212
+ end
213
+ end
214
+ end