rubocop-view_component 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d84d4738ad3da42500e46d9f0b2bb90160eb5501e6dc515fea63bf582917f1f1
4
- data.tar.gz: 9376295000ba2f7c30e8f74e8c707371333e2681454ad402e4d33472f05576a7
3
+ metadata.gz: 89e1184369a777c4428be4b9428e6ed7b8c9f21f1e17f4434afba8eabefa8651
4
+ data.tar.gz: 47a1c93267420a04a18d3bb9a160b4c3d956f3d1ac7d7ea119f144e779908ea7
5
5
  SHA512:
6
- metadata.gz: a4058fb7e22e45a737703dccb110560b2360f66010feccc9afde757ac8551351872cfb9a4cc0022d511c453eb96bfddcb6adde9ecf16426aba8bad34aa4ffc09
7
- data.tar.gz: 27297ff15bb964213a7017cbb37920b5c8950570a405c654a18bcedb8d742a6c6f46a873fa7239653e5ed70b09360fccd36d4fbd670bb5c78385a55266608409
6
+ metadata.gz: 0512f6877bba3a6d9091e51bd18a4e68912738614782bcf9dd435c86507f6add98cae4e9b3c98142ba9e6e204c603cbfb4b31812fee45d6497b005e58436d943
7
+ data.tar.gz: 866a844c2ee8e220aca9b9b553e29c73eba3c275a3fdb55344bf39b18ec4c4bbbe0f7d1a07fe9902ae0b0694e81f91b190ca44831816d121ec1bf36cb72c4536
@@ -0,0 +1,177 @@
1
+ # Implementation Plan: Fix PreferPrivateMethods False Positives
2
+
3
+ ## Problem
4
+
5
+ The `ViewComponent/PreferPrivateMethods` cop currently generates false positives by flagging methods that are called from ERB templates. In ViewComponent, methods called from templates must remain public, otherwise they cause runtime errors.
6
+
7
+ ## Solution Overview
8
+
9
+ Use the `herb` gem to parse ERB templates and extract method calls. Only flag public methods that are NOT called from the component's template(s).
10
+
11
+ ## Implementation Steps
12
+
13
+ ### 1. Add herb Dependency
14
+
15
+ **File:** `rubocop-view_component.gemspec`
16
+
17
+ - Add `spec.add_dependency "herb", "~> 0.1"` (check latest stable version)
18
+
19
+ ### 2. Create Template Finder Helper
20
+
21
+ **File:** `lib/rubocop/cop/view_component/template_analyzer.rb` (new)
22
+
23
+ Create a module `TemplateAnalyzer` with:
24
+
25
+ ```ruby
26
+ module TemplateAnalyzer
27
+ # Find template file(s) for a component
28
+ # Returns array of template file paths (can be empty)
29
+ def template_paths_for(component_file_path)
30
+ # Check for sibling template: same_name.html.erb
31
+ # Check for sidecar template: same_name/same_name.html.erb
32
+ # Handle variants: same_name.variant.html.erb
33
+ end
34
+
35
+ # Extract method calls from ERB template
36
+ def extract_method_calls(template_path)
37
+ # Use Herb.extract_ruby to get Ruby code
38
+ # Parse Ruby code with RuboCop's parser
39
+ # Traverse AST to find method calls (send nodes with nil receiver)
40
+ # Return Set of method names (symbols)
41
+ end
42
+ end
43
+ ```
44
+
45
+ **Implementation details:**
46
+
47
+ - Use `File.exist?` to check for template files
48
+ - Handle both naming conventions (sibling and sidecar)
49
+ - Parse extracted Ruby code using `RuboCop::AST::ProcessedSource`
50
+ - Traverse AST to find `send` nodes with `nil` receiver (local method calls)
51
+ - Handle edge cases:
52
+ - Missing template (component uses `call` method)
53
+ - Multiple templates (variants)
54
+ - Parse errors in template
55
+
56
+ ### 3. Update PreferPrivateMethods Cop
57
+
58
+ **File:** `lib/rubocop/cop/view_component/prefer_private_methods.rb`
59
+
60
+ Modify the cop to:
61
+
62
+ 1. Include `TemplateAnalyzer` module
63
+ 2. In `check_public_methods`, find template paths using the component file path
64
+ 3. Extract method calls from all templates
65
+ 4. Skip offense if method is called from any template
66
+
67
+ ```ruby
68
+ def check_public_methods(class_node)
69
+ current_visibility = :public
70
+ template_method_calls = methods_called_in_templates(class_node)
71
+
72
+ class_node.body&.each_child_node do |child|
73
+ # ... existing visibility tracking ...
74
+
75
+ next unless child.def_type?
76
+ next unless current_visibility == :public
77
+ next if ALLOWED_PUBLIC_METHODS.include?(child.method_name)
78
+ next if template_method_calls.include?(child.method_name) # NEW
79
+
80
+ add_offense(child)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def methods_called_in_templates(class_node)
87
+ component_path = processed_source.file_path
88
+ template_paths = template_paths_for(component_path)
89
+
90
+ template_paths.flat_map { |path| extract_method_calls(path) }.to_set
91
+ rescue => e
92
+ # Log error and return empty set (graceful degradation)
93
+ Set.new
94
+ end
95
+ ```
96
+
97
+ ### 4. Update Tests
98
+
99
+ **File:** `spec/rubocop/cop/view_component/prefer_private_methods_spec.rb`
100
+
101
+ Add new test contexts:
102
+
103
+ 1. **Methods called from template should NOT be flagged**
104
+ - Create fixture component + template
105
+ - Method is public and called in template
106
+ - Expect no offense
107
+
108
+ 2. **Methods NOT called from template should be flagged**
109
+ - Create fixture component + template
110
+ - Method is public but not used in template
111
+ - Expect offense
112
+
113
+ 3. **Component without template**
114
+ - Component has no template file
115
+ - Should fall back to current behavior (flag all non-interface methods)
116
+
117
+ 4. **Component with multiple templates (variants)**
118
+ - Component has multiple template files
119
+ - Method called in any template should not be flagged
120
+
121
+ 5. **Template with parse errors**
122
+ - Invalid ERB syntax
123
+ - Should gracefully degrade (don't flag any methods)
124
+
125
+ **Fixture structure:**
126
+
127
+ ```
128
+ spec/fixtures/components/
129
+ example_component.rb
130
+ example_component.html.erb
131
+ variant_component.rb
132
+ variant_component.html.erb
133
+ variant_component.phone.html.erb
134
+ ```
135
+
136
+ ### 5. Handle Edge Cases
137
+
138
+ - **No template file**: Fall back to current behavior
139
+ - **Invalid ERB**: Catch parse errors, log warning, skip template analysis
140
+ - **Sidecar directories**: Check both naming conventions
141
+ - **Variants**: Find all variant templates (*.html.erb, *.phone.html.erb, etc.)
142
+ - **Conditional method calls**: `<%= foo if condition %>` - still counts as using `foo`
143
+ - **Method chains**: `<%= user.name %>` - only `user` is a method call, not `name`
144
+ - **Block parameters**: `<% items.each do |item| %>` - `item` is not a method
145
+
146
+ ### 6. Documentation Updates
147
+
148
+ **File:** `README.md`
149
+
150
+ Update the PreferPrivateMethods cop description to mention:
151
+ - Now checks ERB templates for method usage
152
+ - Only flags methods not called from templates
153
+ - Requires templates to be in conventional locations
154
+
155
+ ## Testing Strategy
156
+
157
+ 1. Unit tests for `TemplateAnalyzer` methods
158
+ 2. Integration tests for the full cop with fixtures
159
+ 3. Manual testing on real ViewComponent codebases
160
+
161
+ ## Potential Issues
162
+
163
+ 1. **Performance**: Parsing templates for every component could be slow
164
+ - Mitigation: Cache template analysis results
165
+
166
+ 2. **Complex Ruby in ERB**: Nested blocks, conditionals, etc.
167
+ - Mitigation: Robust AST traversal
168
+
169
+ 3. **Dynamic method calls**: `send(:method_name)`, `public_send`, etc.
170
+ - Limitation: Won't detect these (acceptable trade-off)
171
+
172
+ ## Success Criteria
173
+
174
+ - False positive rate drops from ~1,363 to near zero on the reported codebase
175
+ - No new false negatives (methods that should be private but aren't flagged)
176
+ - Tests pass
177
+ - Performance acceptable (< 100ms per component)
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # RuboCop::ViewComponent
2
2
 
3
+ > **Note:** This gem was vibe-coded and is not yet ready for real-world use. It's currently experimental and may have bugs or incomplete features. Contributions are welcome!
4
+
3
5
  A RuboCop extension that enforces [ViewComponent best practices](https://viewcomponent.org/best_practices.html).
4
6
 
5
7
  ## Installation
@@ -23,7 +25,7 @@ This gem provides several cops to enforce ViewComponent best practices:
23
25
 
24
26
  - **ViewComponent/ComponentSuffix** - Enforce `-Component` suffix for ViewComponent classes
25
27
  - **ViewComponent/NoGlobalState** - Prevent direct access to `params`, `request`, `session`, etc.
26
- - **ViewComponent/PreferPrivateMethods** - Suggest making helper methods private
28
+ - **ViewComponent/PreferPrivateMethods** - Suggest making helper methods private (analyzes ERB templates to avoid flagging methods used in views)
27
29
  - **ViewComponent/PreferSlots** - Detect HTML parameters that should be slots
28
30
  - **ViewComponent/PreferComposition** - Discourage deep inheritance chains
29
31
  - **ViewComponent/TestRenderedOutput** - Encourage testing rendered output over private methods
@@ -38,12 +40,42 @@ Run RuboCop as usual:
38
40
  bundle exec rubocop
39
41
  ```
40
42
 
43
+ ## Configuration
44
+
45
+ By default, all cops detect classes that inherit from `ViewComponent::Base` or `ApplicationComponent`. If your project uses a different base class (e.g. `Primer::Component`), you can configure additional parent classes under `AllCops`:
46
+
47
+ ```yaml
48
+ # .rubocop.yml
49
+ AllCops:
50
+ ViewComponentParentClasses:
51
+ - Primer::Component
52
+ - MyApp::BaseComponent
53
+ ```
54
+
55
+ This applies to all ViewComponent cops.
56
+
41
57
  ## Development
42
58
 
43
59
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
44
60
 
45
61
  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).
46
62
 
63
+ ## Primer Verification
64
+
65
+ The cops are tested against [primer/view_components](https://github.com/primer/view_components) as a real-world baseline, and to catch regressions. The script [`verify_against_primer.rb`](script/verify_against_primer.rb) copies the Primer repo, runs all ViewComponent cops against it, and compares the results to a checked-in snapshot ([`expected_primer_failures.json`](spec/expected_primer_failures.json)). This runs automatically in CI.
66
+
67
+ To verify locally:
68
+
69
+ ```bash
70
+ bundle exec ruby script/verify_against_primer.rb
71
+ ```
72
+
73
+ If you intentionally change cop behavior, regenerate the snapshot:
74
+
75
+ ```bash
76
+ bundle exec ruby script/verify_against_primer.rb --regenerate
77
+ ```
78
+
47
79
  ## Contributing
48
80
 
49
81
  Bug reports and pull requests are welcome on GitHub at https://github.com/andyw8/rubocop-view_component.
data/config/default.yml CHANGED
@@ -1,15 +1,18 @@
1
+ AllCops:
2
+ ViewComponentParentClasses: []
3
+
1
4
  ViewComponent/ComponentSuffix:
2
5
  Description: 'Enforce -Component suffix for ViewComponent classes.'
3
6
  Enabled: true
4
7
  VersionAdded: '0.1'
5
- Severity: warning
8
+ Severity: convention
6
9
  StyleGuide: 'https://viewcomponent.org/best_practices.html'
7
10
 
8
11
  ViewComponent/NoGlobalState:
9
12
  Description: 'Avoid accessing global state (params, request, session, cookies, flash) directly.'
10
13
  Enabled: true
11
14
  VersionAdded: '0.1'
12
- Severity: warning
15
+ Severity: convention
13
16
  StyleGuide: 'https://viewcomponent.org/best_practices.html'
14
17
 
15
18
  ViewComponent/PreferPrivateMethods:
@@ -24,10 +27,14 @@ ViewComponent/PreferPrivateMethods:
24
27
  - before_render
25
28
  - before_render_check
26
29
  - render?
30
+ - render_in
31
+ - around_render
32
+ AllowedPublicMethodPatterns:
33
+ - "^with_"
27
34
 
28
35
  ViewComponent/PreferSlots:
29
36
  Description: 'Prefer slots over HTML string parameters.'
30
37
  Enabled: true
31
38
  VersionAdded: '0.1'
32
- Severity: warning
39
+ Severity: convention
33
40
  StyleGuide: 'https://viewcomponent.org/best_practices.html'
@@ -15,12 +15,16 @@ module RuboCop
15
15
  view_component_parent?(parent_class)
16
16
  end
17
17
 
18
- # Check if node represents ViewComponent::Base or ApplicationComponent
18
+ # Check if node represents ViewComponent::Base, ApplicationComponent,
19
+ # or a configured additional parent class
19
20
  def view_component_parent?(node)
20
21
  return false unless node.const_type?
21
22
 
22
23
  source = node.source
23
- source == "ViewComponent::Base" || source == "ApplicationComponent"
24
+ return true if source == "ViewComponent::Base" || source == "ApplicationComponent"
25
+
26
+ additional = config.for_all_cops["ViewComponentParentClasses"] || []
27
+ additional.include?(source)
24
28
  end
25
29
 
26
30
  # Find the enclosing class node
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "template_analyzer"
4
+
3
5
  module RuboCop
4
6
  module Cop
5
7
  module ViewComponent
@@ -24,18 +26,11 @@ module RuboCop
24
26
  #
25
27
  class PreferPrivateMethods < RuboCop::Cop::Base
26
28
  include ViewComponent::Base
29
+ include TemplateAnalyzer
27
30
 
28
31
  MSG = "Consider making this method private. " \
29
32
  "Only ViewComponent interface methods should be public."
30
33
 
31
- ALLOWED_PUBLIC_METHODS = %i[
32
- initialize
33
- call
34
- before_render
35
- before_render_check
36
- render?
37
- ].freeze
38
-
39
34
  def on_class(node)
40
35
  return unless view_component_class?(node)
41
36
 
@@ -46,8 +41,14 @@ module RuboCop
46
41
 
47
42
  def check_public_methods(class_node)
48
43
  current_visibility = :public
44
+ template_method_calls = methods_called_in_templates
45
+
46
+ body = class_node.body
47
+ return unless body
48
+
49
+ children = body.begin_type? ? body.children : [body]
49
50
 
50
- class_node.body&.each_child_node do |child|
51
+ children.each do |child|
51
52
  if visibility_modifier?(child)
52
53
  current_visibility = child.method_name
53
54
  next
@@ -55,12 +56,40 @@ module RuboCop
55
56
 
56
57
  next unless child.def_type?
57
58
  next unless current_visibility == :public
58
- next if ALLOWED_PUBLIC_METHODS.include?(child.method_name)
59
+ next if allowed_public_method?(child.method_name)
60
+ next if template_method_calls.include?(child.method_name)
59
61
 
60
62
  add_offense(child)
61
63
  end
62
64
  end
63
65
 
66
+ def allowed_public_method?(method_name)
67
+ allowed_public_methods.include?(method_name.to_s) ||
68
+ allowed_public_method_patterns.any? { |pattern| method_name.to_s.match?(pattern) }
69
+ end
70
+
71
+ def allowed_public_methods
72
+ cop_config.fetch("AllowedPublicMethods", [])
73
+ end
74
+
75
+ def allowed_public_method_patterns
76
+ cop_config.fetch("AllowedPublicMethodPatterns", []).map { |pattern| Regexp.new(pattern) }
77
+ end
78
+
79
+ def methods_called_in_templates
80
+ component_path = processed_source.file_path
81
+ return Set.new unless component_path
82
+
83
+ template_paths = template_paths_for(component_path)
84
+ template_paths.each_with_object(Set.new) do |path, methods|
85
+ methods.merge(extract_method_calls(path))
86
+ end
87
+ rescue => e
88
+ # Graceful degradation on errors
89
+ warn "Warning: Failed to analyze templates: #{e.message}" if ENV["RUBOCOP_DEBUG"]
90
+ Set.new
91
+ end
92
+
64
93
  def visibility_modifier?(node)
65
94
  return false unless node.send_type?
66
95
  return false unless node.receiver.nil?
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "herb"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module ViewComponent
8
+ # Helper module for analyzing ViewComponent ERB templates
9
+ module TemplateAnalyzer
10
+ # Find template file paths for a component
11
+ # @param component_path [String] Path to the component Ruby file
12
+ # @return [Array<String>] Array of template file paths
13
+ def template_paths_for(component_path)
14
+ return [] unless component_path
15
+
16
+ base_path = component_path.sub(/\.rb$/, "")
17
+ component_dir = File.dirname(component_path)
18
+ component_name = File.basename(component_path, ".rb")
19
+
20
+ paths = []
21
+
22
+ # Check for sibling template: same_name.html.erb
23
+ sibling_template = "#{base_path}.html.erb"
24
+ paths << sibling_template if File.exist?(sibling_template)
25
+
26
+ # Check for sidecar template: same_name/same_name.html.erb
27
+ sidecar_template = File.join(component_dir, component_name, "#{component_name}.html.erb")
28
+ paths << sidecar_template if File.exist?(sidecar_template)
29
+
30
+ # Check for variants: same_name.*.html.erb
31
+ variant_pattern = "#{base_path}.*.html.erb"
32
+ paths.concat(Dir.glob(variant_pattern))
33
+
34
+ # Check for sidecar variants: same_name/same_name.*.html.erb
35
+ sidecar_variant_pattern = File.join(component_dir, component_name, "#{component_name}.*.html.erb")
36
+ paths.concat(Dir.glob(sidecar_variant_pattern))
37
+
38
+ paths.uniq
39
+ end
40
+
41
+ # Extract method calls from an ERB template
42
+ # @param template_path [String] Path to the ERB template file
43
+ # @return [Set<Symbol>] Set of method names called in the template
44
+ def extract_method_calls(template_path)
45
+ return Set.new unless File.exist?(template_path)
46
+
47
+ source = File.read(template_path)
48
+ ruby_code = Herb.extract_ruby(source)
49
+
50
+ # Parse the extracted Ruby code
51
+ parse_ruby_for_method_calls(ruby_code)
52
+ rescue => e
53
+ # Graceful degradation on parse errors
54
+ warn "Warning: Failed to parse template #{template_path}: #{e.message}" if ENV["RUBOCOP_DEBUG"]
55
+ Set.new
56
+ end
57
+
58
+ private
59
+
60
+ # Parse Ruby code and extract method calls (send nodes with nil receiver)
61
+ def parse_ruby_for_method_calls(ruby_code)
62
+ # Use RuboCop's ProcessedSource to parse Ruby code
63
+ processed = RuboCop::ProcessedSource.new(
64
+ ruby_code,
65
+ RuboCop::TargetRuby.supported_versions.max,
66
+ "(template)"
67
+ )
68
+
69
+ return Set.new unless processed.valid_syntax?
70
+
71
+ method_calls = Set.new
72
+ traverse_for_method_calls(processed.ast, method_calls) if processed.ast
73
+ method_calls
74
+ end
75
+
76
+ # Recursively traverse AST to find method calls
77
+ def traverse_for_method_calls(node, method_calls)
78
+ return unless node.respond_to?(:type)
79
+
80
+ # Look for send nodes with nil receiver (local method calls)
81
+ if node.type == :send && node.receiver.nil?
82
+ method_name = node.method_name
83
+ method_calls.add(method_name)
84
+ end
85
+
86
+ # Recursively traverse children
87
+ node.each_child_node do |child|
88
+ traverse_for_method_calls(child, method_calls)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module ViewComponent
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "tmpdir"
6
+ require "fileutils"
7
+ require "open3"
8
+ require "bundler"
9
+
10
+ GEM_DIR = File.expand_path("..", __dir__)
11
+ RESULTS_FILE = File.join(GEM_DIR, "spec", "expected_primer_failures.json")
12
+ TARBALL_URL = "https://github.com/primer/view_components/archive/refs/heads/main.tar.gz"
13
+
14
+ def main
15
+ mode = ARGV.include?("--regenerate") ? :regenerate : :verify
16
+
17
+ Dir.mktmpdir do |dir|
18
+ download_source(dir)
19
+
20
+ Dir.chdir(dir) do
21
+ configure_rubocop
22
+ add_gem_to_gemfile
23
+
24
+ Bundler.with_unbundled_env do
25
+ output = run_rubocop
26
+ offenses = extract_offenses(output)
27
+
28
+ case mode
29
+ when :regenerate then regenerate(offenses)
30
+ when :verify then verify(offenses)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def system!(*args)
38
+ system(*args, exception: true)
39
+ end
40
+
41
+ def download_source(dir)
42
+ puts "Downloading primer/view_components..."
43
+ system!("curl", "-sL", TARBALL_URL, "-o", "#{dir}/source.tar.gz")
44
+ system!("tar", "xz", "-C", dir, "--strip-components=1", "-f", "#{dir}/source.tar.gz")
45
+ end
46
+
47
+ def configure_rubocop
48
+ puts "Configuring ViewComponentParentClasses in .rubocop.yml..."
49
+ config = YAML.load_file(".rubocop.yml") || {}
50
+ config["AllCops"] ||= {}
51
+ parents = config["AllCops"]["ViewComponentParentClasses"] || []
52
+ unless parents.include?("Primer::Component")
53
+ parents << "Primer::Component"
54
+ config["AllCops"]["ViewComponentParentClasses"] = parents
55
+ File.write(".rubocop.yml", YAML.dump(config))
56
+ end
57
+ end
58
+
59
+ def add_gem_to_gemfile
60
+ puts "Adding rubocop-view_component gem to Gemfile..."
61
+ File.open("Gemfile", "a") { |f| f.puts "gem 'rubocop-view_component', path: '#{GEM_DIR}'" }
62
+ end
63
+
64
+ def run_rubocop
65
+ puts "Running bundle install..."
66
+ system!("bundle", "install")
67
+
68
+ puts "Running RuboCop (ViewComponent cops only)..."
69
+ output, status = Open3.capture2(
70
+ "bundle", "exec", "rubocop",
71
+ "--require", "rubocop-view_component",
72
+ "--only", "ViewComponent",
73
+ "--format", "json"
74
+ )
75
+
76
+ puts "RuboCop exit status: #{status.exitstatus}"
77
+
78
+ if output.strip.empty?
79
+ abort "ERROR: RuboCop produced no output (exit status: #{status.exitstatus})"
80
+ end
81
+
82
+ output
83
+ end
84
+
85
+ def extract_offenses(rubocop_output)
86
+ data = JSON.parse(rubocop_output)
87
+ data["files"].flat_map do |file|
88
+ file["offenses"].map do |offense|
89
+ {
90
+ "path" => file["path"],
91
+ "line" => offense["location"]["start_line"],
92
+ "cop" => offense["cop_name"],
93
+ "message" => offense["message"]
94
+ }
95
+ end
96
+ end
97
+ end
98
+
99
+ def regenerate(offenses)
100
+ json = "#{JSON.pretty_generate(offenses)}\n"
101
+ File.write(RESULTS_FILE, json)
102
+ puts "#{offenses.length} offense(s) written to #{RESULTS_FILE}"
103
+ end
104
+
105
+ def verify(offenses)
106
+ unless File.exist?(RESULTS_FILE)
107
+ abort "ERROR: #{RESULTS_FILE} not found. Run '#{$PROGRAM_NAME} --regenerate' first."
108
+ end
109
+
110
+ current_json = JSON.pretty_generate(offenses)
111
+ expected_json = File.read(RESULTS_FILE)
112
+
113
+ if current_json.strip == expected_json.strip
114
+ puts "Verification passed: output matches #{RESULTS_FILE}"
115
+ else
116
+ puts "Verification failed: output differs from #{RESULTS_FILE}"
117
+ expected = JSON.parse(expected_json)
118
+ added = offenses - expected
119
+ removed = expected - offenses
120
+
121
+ added.each { |o| puts " + #{o["cop"]}: #{o["path"]}:#{o["line"]}" }
122
+ removed.each { |o| puts " - #{o["cop"]}: #{o["path"]}:#{o["line"]}" }
123
+
124
+ exit 1
125
+ end
126
+ end
127
+
128
+ main