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 +4 -4
- data/IMPLEMENTATION_PLAN.md +177 -0
- data/README.md +33 -1
- data/config/default.yml +10 -3
- data/lib/rubocop/cop/view_component/base.rb +6 -2
- data/lib/rubocop/cop/view_component/prefer_private_methods.rb +39 -10
- data/lib/rubocop/cop/view_component/template_analyzer.rb +94 -0
- data/lib/rubocop/view_component/version.rb +1 -1
- data/script/verify_against_primer.rb +128 -0
- data/spec/expected_primer_failures.json +818 -0
- data/spec/fixtures/components/template_method_component.html.erb +3 -0
- data/spec/fixtures/components/template_method_component.rb +15 -0
- data/spec/rubocop/cop/view_component/component_suffix_spec.rb +33 -0
- data/spec/rubocop/cop/view_component/prefer_private_methods_spec.rb +163 -0
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89e1184369a777c4428be4b9428e6ed7b8c9f21f1e17f4434afba8eabefa8651
|
|
4
|
+
data.tar.gz: 47a1c93267420a04a18d3bb9a160b4c3d956f3d1ac7d7ea119f144e779908ea7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|