rubocop-view_component 0.2.0 → 0.3.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: 89e1184369a777c4428be4b9428e6ed7b8c9f21f1e17f4434afba8eabefa8651
4
- data.tar.gz: 47a1c93267420a04a18d3bb9a160b4c3d956f3d1ac7d7ea119f144e779908ea7
3
+ metadata.gz: 60cf1f19ad3af3d8c80a7da526c8fcc77658723067e67bb42b6ce4bd97709302
4
+ data.tar.gz: ac9a362b04a16a58549e0733c72fbf874ae7ca954ce0509eac5fe763f00283c5
5
5
  SHA512:
6
- metadata.gz: 0512f6877bba3a6d9091e51bd18a4e68912738614782bcf9dd435c86507f6add98cae4e9b3c98142ba9e6e204c603cbfb4b31812fee45d6497b005e58436d943
7
- data.tar.gz: 866a844c2ee8e220aca9b9b553e29c73eba3c275a3fdb55344bf39b18ec4c4bbbe0f7d1a07fe9902ae0b0694e81f91b190ca44831816d121ec1bf36cb72c4536
6
+ metadata.gz: 7282cc87157b8b189a735b776cf93d48f9084b93a60a637e8a349806cef1706d1aa3581a308a2ccac21cdff7fe7613dd6fd0098c5d542493f2a98c87b8280bea
7
+ data.tar.gz: bc67d21c75e60619c11e75db291f602f8a9967a7b1c31041341207a4ccba72e9aafe3029fd1efdb9db2a60c36b68e4b961e7fe40c50bd50d8023958ea0472944
data/CLAUDE.md ADDED
@@ -0,0 +1,72 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ This is a RuboCop extension that enforces ViewComponent best practices. It provides custom cops that detect anti-patterns and style issues specific to ViewComponent development.
8
+
9
+ ## Common Commands
10
+
11
+ ### Testing
12
+ - `rake spec` - Run all RSpec tests
13
+ - `bundle exec rspec spec/rubocop/cop/view_component/FILENAME_spec.rb` - Run a specific spec file
14
+ - `rake standard` - Run Standard (RuboCop) linting
15
+ - `rake` - Run both tests and linting (default task)
16
+
17
+ ### Verification
18
+ The project includes a verification system that tests cops against real-world component libraries:
19
+
20
+ - `script/verify primer` - Verify against Primer ViewComponents
21
+ - `script/verify govuk` - Verify against x-govuk components
22
+ - `script/verify polaris` - Verify against Polaris ViewComponents
23
+ - `script/verify LIBRARY --regenerate` - Update expected results after intentional changes
24
+ - `script/verify LIBRARY --update` - Force re-download latest library source
25
+
26
+ ### Development
27
+ - `bundle exec rake new_cop[ViewComponent/CopName]` - Generate a new cop with template files
28
+
29
+ ## Code Architecture
30
+
31
+ ### Cop Structure
32
+
33
+ All cops inherit from `RuboCop::Cop::Base` and are located in `lib/rubocop/cop/view_component/`. Each cop:
34
+
35
+ 1. Includes `ViewComponent::Base` module for shared helper methods
36
+ 2. Defines detection logic in `on_class`, `on_def`, or other AST node callbacks
37
+ 3. Has configuration in `config/default.yml`
38
+ 4. Has corresponding specs in `spec/rubocop/cop/view_component/`
39
+
40
+ ### Shared Modules
41
+
42
+ **`ViewComponent::Base`** (`lib/rubocop/cop/view_component/base.rb`)
43
+ - Provides `view_component_class?(node)` - Detects ViewComponent classes
44
+ - Provides `view_component_parent?(node)` - Checks if inheriting from ViewComponent::Base, ApplicationComponent, or configured parent classes
45
+ - Provides `inside_view_component?(node)` - Checks if code is within a ViewComponent
46
+
47
+ **`TemplateAnalyzer`** (`lib/rubocop/cop/view_component/template_analyzer.rb`)
48
+ - Used by PreferPrivateMethods cop to analyze ERB templates
49
+ - Extracts method calls from templates to avoid flagging template-used methods as private candidates
50
+ - Handles both sibling templates (`component.html.erb`) and sidecar templates (`component/component.html.erb`)
51
+ - Uses the `herb` gem to parse ERB and extract Ruby code
52
+
53
+ ### Configuration
54
+
55
+ The `AllCops` config supports `ViewComponentParentClasses` to configure additional base classes beyond `ViewComponent::Base` and `ApplicationComponent`:
56
+
57
+ ```yaml
58
+ AllCops:
59
+ ViewComponentParentClasses:
60
+ - MyApp::BaseComponent
61
+ ```
62
+
63
+ ### Verification System
64
+
65
+ The `script/verify` script downloads real component libraries, runs all ViewComponent cops, and compares results to checked-in snapshots. This catches regressions when cop behavior changes. Libraries are configured in `verification/libraries.yml`, downloaded to `verification/LIBRARY/`, and expected results stored in `spec/expected_LIBRARY_failures.json`.
66
+
67
+ ## Implementation Notes
68
+
69
+ - When adding a new cop, use `rake new_cop[ViewComponent/CopName]` to generate the boilerplate
70
+ - Template analysis is performance-sensitive - `PreferPrivateMethods` uses `herb` gem for efficient ERB parsing
71
+ - Cops must handle graceful degradation when templates can't be parsed
72
+ - All cops should include `ViewComponent::Base` module to get detection helpers
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
- # RuboCop::ViewComponent
1
+ # rubocop-view_component
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
-
5
- A RuboCop extension that enforces [ViewComponent best practices](https://viewcomponent.org/best_practices.html).
3
+ A RuboCop extension that encourages [ViewComponent best practices](https://viewcomponent.org/best_practices.html).
6
4
 
7
5
  ## Installation
8
6
 
@@ -27,32 +25,32 @@ This gem provides several cops to enforce ViewComponent best practices:
27
25
  - **ViewComponent/NoGlobalState** - Prevent direct access to `params`, `request`, `session`, etc.
28
26
  - **ViewComponent/PreferPrivateMethods** - Suggest making helper methods private (analyzes ERB templates to avoid flagging methods used in views)
29
27
  - **ViewComponent/PreferSlots** - Detect HTML parameters that should be slots
30
- - **ViewComponent/PreferComposition** - Discourage deep inheritance chains
28
+ - **ViewComponent/PreferComposition** - Avoid inheriting one ViewComponent from another (prefer composition)
31
29
  - **ViewComponent/TestRenderedOutput** - Encourage testing rendered output over private methods
32
30
 
33
- See [PLAN.md](PLAN.md) for detailed cop descriptions and implementation status.
34
-
35
- ## Usage
36
-
37
- Run RuboCop as usual:
38
-
39
- ```bash
40
- bundle exec rubocop
41
- ```
31
+ ## Optional Configuration
42
32
 
43
- ## Configuration
33
+ ### Base Class
44
34
 
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`:
35
+ By default, the 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`, for example:
46
36
 
47
37
  ```yaml
48
38
  # .rubocop.yml
49
39
  AllCops:
50
40
  ViewComponentParentClasses:
51
- - Primer::Component
52
41
  - MyApp::BaseComponent
53
42
  ```
54
43
 
55
- This applies to all ViewComponent cops.
44
+ ### No Super
45
+
46
+ View Component convention is to not calling `super` in component initializers, but that may cause `Lint/MissingSuper` failures from RuboCop. We suggest disabling that rule for your view components directory, for example:
47
+
48
+ ```yaml
49
+ # .rubocop.yml
50
+ Lint/MissingSuper:
51
+ Exclude:
52
+ - 'app/components/**/*'
53
+ ```
56
54
 
57
55
  ## Development
58
56
 
@@ -60,20 +58,70 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
60
58
 
61
59
  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).
62
60
 
63
- ## Primer Verification
61
+ ## Real-World Verification
62
+
63
+ The cops are tested against real-world component libraries as baselines to catch regressions.
64
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.
65
+ The [`script/verify`](script/verify) script downloads component libraries (cached in `verification/`), runs all ViewComponent cops against them, and compares the results to checked-in snapshots. This runs automatically in CI.
66
66
 
67
- To verify locally:
67
+ ### Primer ViewComponents
68
+
69
+ To verify against [primer/view_components](https://github.com/primer/view_components) locally:
68
70
 
69
71
  ```bash
70
- bundle exec ruby script/verify_against_primer.rb
72
+ script/verify primer
71
73
  ```
72
74
 
73
75
  If you intentionally change cop behavior, regenerate the snapshot:
74
76
 
75
77
  ```bash
76
- bundle exec ruby script/verify_against_primer.rb --regenerate
78
+ script/verify primer --regenerate
79
+ ```
80
+
81
+ To force download the latest Primer source:
82
+
83
+ ```bash
84
+ script/verify primer --update
85
+ ```
86
+
87
+ ### x-govuk Components
88
+
89
+ To verify against [x-govuk/govuk-components](https://github.com/x-govuk/govuk-components) locally:
90
+
91
+ ```bash
92
+ script/verify govuk
93
+ ```
94
+
95
+ If you intentionally change cop behavior, regenerate the snapshot:
96
+
97
+ ```bash
98
+ script/verify govuk --regenerate
99
+ ```
100
+
101
+ To force download the latest x-govuk source:
102
+
103
+ ```bash
104
+ script/verify govuk --update
105
+ ```
106
+
107
+ ### Polaris ViewComponents
108
+
109
+ To verify against [baoagency/polaris_view_components](https://github.com/baoagency/polaris_view_components) locally:
110
+
111
+ ```bash
112
+ script/verify polaris
113
+ ```
114
+
115
+ If you intentionally change cop behavior, regenerate the snapshot:
116
+
117
+ ```bash
118
+ script/verify polaris --regenerate
119
+ ```
120
+
121
+ To force download the latest Polaris source:
122
+
123
+ ```bash
124
+ script/verify polaris --update
77
125
  ```
78
126
 
79
127
  ## Contributing
data/config/default.yml CHANGED
@@ -32,9 +32,26 @@ ViewComponent/PreferPrivateMethods:
32
32
  AllowedPublicMethodPatterns:
33
33
  - "^with_"
34
34
 
35
+ ViewComponent/PreferComposition:
36
+ Description: 'Prefer composition over inheritance for ViewComponents.'
37
+ Enabled: true
38
+ VersionAdded: '0.3'
39
+ Severity: convention
40
+ StyleGuide: 'https://viewcomponent.org/best_practices.html'
41
+
35
42
  ViewComponent/PreferSlots:
36
43
  Description: 'Prefer slots over HTML string parameters.'
37
44
  Enabled: true
38
45
  VersionAdded: '0.1'
39
46
  Severity: convention
40
47
  StyleGuide: 'https://viewcomponent.org/best_practices.html'
48
+
49
+ ViewComponent/TestRenderedOutput:
50
+ Description: 'Test rendered output instead of component instance methods.'
51
+ Enabled: true
52
+ VersionAdded: '0.3'
53
+ Severity: convention
54
+ StyleGuide: 'https://viewcomponent.org/guide/testing.html'
55
+ Include:
56
+ - 'spec/components/**/*_spec.rb'
57
+ - 'test/components/**/*_test.rb'
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Detects ViewComponent classes that inherit from another component
7
+ # instead of using composition. Inheriting one component from another
8
+ # causes confusion when each has its own template.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # class UserCardComponent < BaseCardComponent
13
+ # end
14
+ #
15
+ # # good
16
+ # class UserCardComponent < ViewComponent::Base
17
+ # # Render BaseCardComponent within template via composition
18
+ # end
19
+ #
20
+ class PreferComposition < RuboCop::Cop::Base
21
+ include ViewComponent::Base
22
+
23
+ MSG = "Avoid inheriting from another ViewComponent."
24
+
25
+ def on_class(node)
26
+ parent_class = node.parent_class
27
+ return unless parent_class
28
+ return if view_component_parent?(parent_class)
29
+ return unless component_like_parent?(parent_class)
30
+
31
+ add_offense(parent_class)
32
+ end
33
+
34
+ private
35
+
36
+ def component_like_parent?(node)
37
+ return false unless node.const_type?
38
+
39
+ node.source.end_with?("Component")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -28,7 +28,7 @@ module RuboCop
28
28
  include ViewComponent::Base
29
29
  include TemplateAnalyzer
30
30
 
31
- MSG = "Consider making this method private. " \
31
+ MSG = "Consider making `%<method_name>s` private. " \
32
32
  "Only ViewComponent interface methods should be public."
33
33
 
34
34
  def on_class(node)
@@ -59,7 +59,7 @@ module RuboCop
59
59
  next if allowed_public_method?(child.method_name)
60
60
  next if template_method_calls.include?(child.method_name)
61
61
 
62
- add_offense(child)
62
+ add_offense(child, message: format(MSG, method_name: child.method_name))
63
63
  end
64
64
  end
65
65
 
@@ -29,20 +29,7 @@ module RuboCop
29
29
  MSG = "Consider using `%<slot_method>s` instead of passing HTML " \
30
30
  "as a parameter. This maintains Rails' automatic HTML escaping."
31
31
 
32
- HTML_PARAM_PATTERNS = [
33
- /_html$/,
34
- /_content$/,
35
- /^html_/,
36
- /^content$/
37
- ].freeze
38
-
39
- # Exclude common non-HTML parameters
40
- EXCLUDED_PARAMS = %i[
41
- html_class
42
- html_classes
43
- html_id
44
- html_tag
45
- ].freeze
32
+ HTML_PARAM_PATTERN = /_html$/
46
33
 
47
34
  def_node_search :html_safe_call?, "(send _ :html_safe)"
48
35
 
@@ -69,9 +56,6 @@ module RuboCop
69
56
 
70
57
  param_name = arg.children[0]
71
58
 
72
- # Skip excluded parameters
73
- next if EXCLUDED_PARAMS.include?(param_name)
74
-
75
59
  # Check parameter name patterns
76
60
  if html_param_name?(param_name)
77
61
  suggested_slot = suggest_slot_name(param_name)
@@ -88,15 +72,11 @@ module RuboCop
88
72
  end
89
73
 
90
74
  def html_param_name?(name)
91
- HTML_PARAM_PATTERNS.any? { |pattern| pattern.match?(name.to_s) }
75
+ HTML_PARAM_PATTERN.match?(name.to_s)
92
76
  end
93
77
 
94
78
  def suggest_slot_name(param_name)
95
- clean_name = param_name.to_s
96
- .sub(/_html$/, "")
97
- .sub(/_content$/, "")
98
- .sub(/^html_/, "")
99
-
79
+ clean_name = param_name.to_s.sub(/_html$/, "")
100
80
  "renders_one :#{clean_name}"
101
81
  end
102
82
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ViewComponent
6
+ # Ensures that ViewComponent tests use `render_inline` to test rendered output
7
+ # rather than testing component methods directly.
8
+ #
9
+ # This cop is only enabled for test files by default (see config).
10
+ #
11
+ # @example
12
+ # # bad
13
+ # def test_formatted_title
14
+ # component = TitleComponent.new("hello")
15
+ # assert_equal "HELLO", component.formatted_title
16
+ # end
17
+ #
18
+ # # good
19
+ # def test_formatted_title
20
+ # render_inline TitleComponent.new("hello")
21
+ # assert_text "HELLO"
22
+ # end
23
+ #
24
+ class TestRenderedOutput < RuboCop::Cop::Base
25
+ MSG = "Test instantiates a component but doesn't use `render_inline` or `render_preview`. " \
26
+ "Test the rendered output instead of component methods directly."
27
+
28
+ # Check Minitest-style test methods
29
+ def on_def(node)
30
+ method_name = node.method_name.to_s
31
+ return unless method_name.start_with?("test_")
32
+ return unless instantiates_component?(node)
33
+ return if contains_render_method?(node)
34
+
35
+ add_offense(node)
36
+ end
37
+
38
+ # Check RSpec-style it blocks
39
+ def on_block(node)
40
+ return unless rspec_it_block?(node)
41
+ return unless instantiates_component?(node)
42
+ return if contains_render_method?(node)
43
+
44
+ add_offense(node)
45
+ end
46
+
47
+ private
48
+
49
+ def instantiates_component?(node)
50
+ node.each_descendant(:send).any? do |send_node|
51
+ next unless send_node.method_name == :new
52
+
53
+ send_node.receiver&.const_type?
54
+ end
55
+ end
56
+
57
+ def contains_render_method?(node)
58
+ node.each_descendant(:send).any? do |send_node|
59
+ %i[render_inline render_preview].include?(send_node.method_name)
60
+ end
61
+ end
62
+
63
+ def rspec_it_block?(node)
64
+ send_node = node.send_node
65
+ return false unless send_node
66
+
67
+ %i[it specify example].include?(send_node.method_name)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -4,4 +4,6 @@ require_relative "view_component/base"
4
4
  require_relative "view_component/component_suffix"
5
5
  require_relative "view_component/no_global_state"
6
6
  require_relative "view_component/prefer_private_methods"
7
+ require_relative "view_component/prefer_composition"
7
8
  require_relative "view_component/prefer_slots"
9
+ require_relative "view_component/test_rendered_output"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module ViewComponent
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/script/verify ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "yaml"
6
+ require "tmpdir"
7
+ require "fileutils"
8
+ require "open3"
9
+ require "bundler"
10
+
11
+ GEM_DIR = File.expand_path("..", __dir__)
12
+ LIBRARIES_CONFIG = File.join(GEM_DIR, "verification", "libraries.yml")
13
+
14
+ def load_libraries
15
+ config = YAML.load_file(LIBRARIES_CONFIG)
16
+
17
+ # Build full paths for each library
18
+ config.transform_values do |library|
19
+ library_key = config.key(library)
20
+ {
21
+ tarball_url: library["tarball_url"],
22
+ verification_dir: File.join(GEM_DIR, "verification", library_key),
23
+ config_file: File.join(GEM_DIR, "verification", "#{library_key}_rubocop_config.yml"),
24
+ results_file: File.join(GEM_DIR, "spec", "expected_#{library_key}_failures.json"),
25
+ display_name: library["display_name"]
26
+ }
27
+ end
28
+ end
29
+
30
+ def main
31
+ libraries = load_libraries
32
+ library = parse_library_arg(libraries)
33
+
34
+ config = libraries[library]
35
+ mode = ARGV.include?("--regenerate") ? :regenerate : :verify
36
+ force_update = ARGV.include?("--update")
37
+
38
+ if force_update && Dir.exist?(config[:verification_dir])
39
+ puts "Removing existing #{config[:display_name]} source for update..."
40
+ FileUtils.rm_rf(config[:verification_dir])
41
+ end
42
+
43
+ if Dir.exist?(config[:verification_dir]) && !Dir.empty?(config[:verification_dir])
44
+ puts "Using existing #{config[:display_name]} source at #{config[:verification_dir]}"
45
+ else
46
+ FileUtils.mkdir_p(config[:verification_dir])
47
+ download_source(config[:verification_dir], config[:tarball_url], config[:display_name])
48
+ end
49
+
50
+ Dir.chdir(config[:verification_dir]) do
51
+ configure_rubocop(config[:config_file], config[:display_name])
52
+ add_gem_to_gemfile
53
+
54
+ Bundler.with_unbundled_env do
55
+ output = run_rubocop
56
+ offenses = extract_offenses(output)
57
+
58
+ case mode
59
+ when :regenerate then regenerate(offenses, config[:results_file])
60
+ when :verify then verify(offenses, config[:results_file])
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ def parse_library_arg(libraries)
67
+ # Find the library argument (not --flags)
68
+ library_arg = ARGV.find { |arg| !arg.start_with?("--") }
69
+
70
+ if library_arg.nil?
71
+ abort "Usage: #{$PROGRAM_NAME} <library> [--regenerate] [--update]\n" \
72
+ " library: #{libraries.keys.join(", ")}"
73
+ end
74
+
75
+ unless libraries.key?(library_arg)
76
+ abort "ERROR: Unknown library '#{library_arg}'. Valid options: #{libraries.keys.join(", ")}"
77
+ end
78
+
79
+ library_arg
80
+ end
81
+
82
+ def system!(*args)
83
+ system(*args, exception: true)
84
+ end
85
+
86
+ def download_source(dir, tarball_url, display_name)
87
+ puts "Downloading #{display_name}..."
88
+ system!("curl", "-sL", tarball_url, "-o", "#{dir}/source.tar.gz")
89
+ system!("tar", "xz", "-C", dir, "--strip-components=1", "-f", "#{dir}/source.tar.gz")
90
+ end
91
+
92
+ def configure_rubocop(config_file, display_name)
93
+ puts "Configuring .rubocop.yml with #{display_name} overrides..."
94
+ config = File.exist?(".rubocop.yml") ? YAML.load_file(".rubocop.yml") : {}
95
+ library_config = YAML.load_file(config_file)
96
+
97
+ # Merge the library config into the existing config
98
+ library_config.each do |key, value|
99
+ config[key] = if config[key].is_a?(Hash) && value.is_a?(Hash)
100
+ config[key].merge(value)
101
+ else
102
+ value
103
+ end
104
+ end
105
+
106
+ File.write(".rubocop.yml", YAML.dump(config))
107
+ end
108
+
109
+ def add_gem_to_gemfile
110
+ puts "Adding rubocop-view_component gem to Gemfile..."
111
+ File.open("Gemfile", "a") { |f| f.puts "gem 'rubocop-view_component', path: '#{GEM_DIR}'" }
112
+ end
113
+
114
+ def run_rubocop
115
+ puts "Running bundle install..."
116
+ system!("bundle", "install")
117
+
118
+ puts "Running RuboCop (ViewComponent cops only)..."
119
+ output, status = Open3.capture2(
120
+ "bundle", "exec", "rubocop",
121
+ "--require", "rubocop-view_component",
122
+ "--only", "ViewComponent",
123
+ "--format", "json"
124
+ )
125
+
126
+ puts "RuboCop exit status: #{status.exitstatus}"
127
+
128
+ if output.strip.empty?
129
+ abort "ERROR: RuboCop produced no output (exit status: #{status.exitstatus})"
130
+ end
131
+
132
+ output
133
+ end
134
+
135
+ def extract_offenses(rubocop_output)
136
+ data = JSON.parse(rubocop_output)
137
+ data["files"].flat_map do |file|
138
+ file["offenses"].map do |offense|
139
+ {
140
+ "path" => file["path"],
141
+ "line" => offense["location"]["start_line"],
142
+ "cop" => offense["cop_name"],
143
+ "message" => offense["message"]
144
+ }
145
+ end
146
+ end
147
+ end
148
+
149
+ def regenerate(offenses, results_file)
150
+ json = "#{JSON.pretty_generate(offenses)}\n"
151
+ File.write(results_file, json)
152
+ puts "#{offenses.length} offense(s) written to #{results_file}"
153
+ end
154
+
155
+ def verify(offenses, results_file)
156
+ unless File.exist?(results_file)
157
+ abort "ERROR: #{results_file} not found. Run '#{$PROGRAM_NAME} --regenerate' first."
158
+ end
159
+
160
+ current_json = JSON.pretty_generate(offenses)
161
+ expected_json = File.read(results_file)
162
+
163
+ if current_json.strip == expected_json.strip
164
+ puts "Verification passed: output matches #{results_file}"
165
+ else
166
+ puts "Verification failed: output differs from #{results_file}"
167
+ expected = JSON.parse(expected_json)
168
+ added = offenses - expected
169
+ removed = expected - offenses
170
+
171
+ added.each { |o| puts " + #{o["cop"]}: #{o["path"]}:#{o["line"]}" }
172
+ removed.each { |o| puts " - #{o["cop"]}: #{o["path"]}:#{o["line"]}" }
173
+
174
+ exit 1
175
+ end
176
+ end
177
+
178
+ main