stimulus-viz 0.1.0 → 0.1.1

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: 9dd3c9161d354f992c21bb961d52c51c41c41fd030a3b0202fa0ff1e8cf5c2ec
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 391418ec9f2ed71ca8b2c73f194e72631e12f69e7f6e031c87ce0b1662904cba
4
+ data.tar.gz: 7471e04568f5f51f191a2fec5bbf208c46f197f43f3b898567cfd748e2ef1bea
5
5
  SHA512:
6
- metadata.gz: f114a88b5b966ffcb6c6f1116c22610c9c8b70f432f6dd790780b251aafaca07d3dd6e2dea05e89ab407147612d4e7f45596b291b17d4bdac99b855e2ae2f985
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: 13d4bd2ff809adfb080cca759222a8b7348e567c053417b6aea109db34a064d3ea99ed9387c069ae4de2dc20c7085492562b22199ecb69c005246e790bb5955f
7
+ data.tar.gz: d55bdd8ac5525a40b1a33a7d45e9b684a7f85f77635acc87df935298d6299433f7e8bcffdb3d2d3dd4aa07a14cffb9f9c835a1f00e7c1e857ad86c6cc880e48e
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.1] - 2024-01-01
9
+
10
+ ### Changed
11
+ - Update gemspec metadata for better discoverability
12
+ - Improve gem description and summary
13
+
14
+ ## [0.1.0] - 2024-01-01
15
+
16
+ ### Added
17
+ - Initial release of StimulusViz
18
+ - Static analysis of Stimulus controllers in Rails projects
19
+ - DOM binding extraction from ERB templates
20
+ - Lint checks for unknown controllers, suspicious actions, and empty bindings
21
+ - CLI commands: scan, list, bindings, lint, export
22
+ - JSON and DOT export formats
23
+ - Comprehensive test suite using yasslab/sample_apps fixtures
24
+ - Support for Ruby >= 3.1
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 StimulusViz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # StimulusViz
2
+
3
+ Rails/Hotwire Stimulus visualization tool for static analysis of Stimulus controllers and DOM bindings.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'stimulus-viz'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install stimulus-viz
20
+
21
+ ## Setup
22
+
23
+ If you're using the development version with git submodules:
24
+
25
+ ```bash
26
+ git submodule update --init --recursive
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### Scan Rails Project
32
+
33
+ Scan your Rails project for Stimulus controllers and DOM bindings:
34
+
35
+ ```bash
36
+ stimulus-viz scan --root /path/to/rails/project --out .stimulus-viz.json
37
+ ```
38
+
39
+ ### List Controllers
40
+
41
+ List all discovered controllers:
42
+
43
+ ```bash
44
+ stimulus-viz list --cache .stimulus-viz.json
45
+ ```
46
+
47
+ ### Show Bindings
48
+
49
+ Show DOM bindings, optionally filtered by controller:
50
+
51
+ ```bash
52
+ stimulus-viz bindings --cache .stimulus-viz.json
53
+ stimulus-viz bindings --cache .stimulus-viz.json --controller presence
54
+ ```
55
+
56
+ ### Lint Analysis
57
+
58
+ Run lint checks on your Stimulus usage:
59
+
60
+ ```bash
61
+ stimulus-viz lint --cache .stimulus-viz.json
62
+ stimulus-viz lint --cache .stimulus-viz.json --fail-on warn
63
+ ```
64
+
65
+ Lint levels:
66
+ - `info`: Empty bindings (controller without actions/targets/values)
67
+ - `warn`: Unknown controllers, suspicious action formats
68
+ - `error`: Critical issues
69
+
70
+ ### Export Data
71
+
72
+ Export scan results in different formats:
73
+
74
+ ```bash
75
+ # Export as pretty JSON
76
+ stimulus-viz export --cache .stimulus-viz.json --format json --out output.json
77
+
78
+ # Export as DOT graph
79
+ stimulus-viz export --cache .stimulus-viz.json --format dot --out graph.dot
80
+ ```
81
+
82
+ ## Output Schema
83
+
84
+ The tool generates JSON with the following structure:
85
+
86
+ ```json
87
+ {
88
+ "meta": {
89
+ "root": "/path/to/project",
90
+ "generated_at": "2024-01-01T12:00:00Z"
91
+ },
92
+ "controllers": [
93
+ {
94
+ "name": "presence",
95
+ "module": "app/javascript/controllers/presence_controller.js",
96
+ "elements": 3,
97
+ "actions": ["highlight", "connect"],
98
+ "targets": ["list", "item"],
99
+ "values": ["fadeMs", "url"]
100
+ }
101
+ ],
102
+ "bindings": [
103
+ {
104
+ "id": "el_0001",
105
+ "selector": "app/views/home/index.html.erb:42 <div#presence>",
106
+ "controllers": ["presence"],
107
+ "actions": ["turbo:before-stream-render->presence#highlight"],
108
+ "targets": [
109
+ {
110
+ "controller": "presence",
111
+ "name": "list",
112
+ "selector": "(static)"
113
+ }
114
+ ],
115
+ "values": [
116
+ {
117
+ "controller": "presence",
118
+ "name": "fadeMs",
119
+ "value": "250"
120
+ }
121
+ ],
122
+ "broken": false
123
+ }
124
+ ],
125
+ "lint": [
126
+ {
127
+ "level": "warn",
128
+ "title": "Unknown controller",
129
+ "detail": "Controller 'missing' is referenced but not found",
130
+ "hint": "Check controller name spelling",
131
+ "where": "app/views/test/index.html.erb:10"
132
+ }
133
+ ]
134
+ }
135
+ ```
136
+
137
+ ## Development
138
+
139
+ After checking out the repo, run:
140
+
141
+ ```bash
142
+ git submodule update --init --recursive
143
+ bundle install
144
+ ```
145
+
146
+ To run tests:
147
+
148
+ ```bash
149
+ bundle exec rake test
150
+ ```
151
+
152
+ To install this gem onto your local machine:
153
+
154
+ ```bash
155
+ bundle exec rake install
156
+ ```
157
+
158
+ ## Testing
159
+
160
+ The test suite uses the [yasslab/sample_apps](https://github.com/yasslab/sample_apps) repository as fixtures via git submodules. Tests create temporary directories with minimal Stimulus setups to verify scanning functionality.
161
+
162
+ ## Contributing
163
+
164
+ Bug reports and pull requests are welcome on GitHub.
165
+
166
+ ## License
167
+
168
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/test_*.rb"]
10
+ end
11
+
12
+ task default: :test
data/example.json ADDED
@@ -0,0 +1,135 @@
1
+ {
2
+ "meta": {
3
+ "root": "/Users/minorisugimura/GitHub/stimulus-viz/example_project",
4
+ "generated_at": "2026-01-01T23:49:27+09:00"
5
+ },
6
+ "controllers": [
7
+ {
8
+ "name": "hello",
9
+ "module": "app/javascript/controllers/hello_controller.js",
10
+ "elements": 2,
11
+ "actions": [],
12
+ "targets": [],
13
+ "values": [
14
+ "name"
15
+ ]
16
+ }
17
+ ],
18
+ "bindings": [
19
+ {
20
+ "id": "el_0001",
21
+ "selector": "app/views/home/index.html.erb:1 <div data-controller=...>",
22
+ "controllers": [
23
+ "hello"
24
+ ],
25
+ "actions": [],
26
+ "targets": [],
27
+ "values": [
28
+ {
29
+ "controller": "hello",
30
+ "name": "name",
31
+ "value": "World"
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "id": "el_0002",
37
+ "selector": "app/views/home/index.html.erb:2 <input data-hello-tar...>",
38
+ "controllers": [],
39
+ "actions": [],
40
+ "targets": [
41
+ {
42
+ "controller": "hello",
43
+ "name": "name",
44
+ "selector": "(static)"
45
+ }
46
+ ],
47
+ "values": []
48
+ },
49
+ {
50
+ "id": "el_0003",
51
+ "selector": "app/views/home/index.html.erb:3 <button data-action=\"...>",
52
+ "controllers": [],
53
+ "actions": [
54
+ "click->hello#greet"
55
+ ],
56
+ "targets": [],
57
+ "values": []
58
+ },
59
+ {
60
+ "id": "el_0004",
61
+ "selector": "app/views/home/index.html.erb:4 <p data-hello-target=...>",
62
+ "controllers": [],
63
+ "actions": [],
64
+ "targets": [
65
+ {
66
+ "controller": "hello",
67
+ "name": "output",
68
+ "selector": "(static)"
69
+ }
70
+ ],
71
+ "values": []
72
+ },
73
+ {
74
+ "id": "el_0005",
75
+ "selector": "app/views/home/index.html.erb:7 <div data-controller=...>",
76
+ "controllers": [
77
+ "unknown-controller"
78
+ ],
79
+ "actions": [],
80
+ "targets": [],
81
+ "values": [],
82
+ "broken": true
83
+ },
84
+ {
85
+ "id": "el_0006",
86
+ "selector": "app/views/home/index.html.erb:8 <span data-action=\"in...>",
87
+ "controllers": [],
88
+ "actions": [
89
+ "invalid-format"
90
+ ],
91
+ "targets": [],
92
+ "values": []
93
+ },
94
+ {
95
+ "id": "el_0007",
96
+ "selector": "app/views/home/index.html.erb:11 <div data-controller=...>",
97
+ "controllers": [
98
+ "hello"
99
+ ],
100
+ "actions": [],
101
+ "targets": [],
102
+ "values": [],
103
+ "broken": true
104
+ }
105
+ ],
106
+ "lint": [
107
+ {
108
+ "level": "warn",
109
+ "title": "Unknown controller",
110
+ "detail": "Controller 'unknown-controller' is referenced but not found in controllers directory",
111
+ "where": "app/views/home/index.html.erb:7 <div data-controller=...>"
112
+ },
113
+ {
114
+ "level": "info",
115
+ "title": "Empty binding",
116
+ "detail": "Element has data-controller but no actions, targets, or values",
117
+ "hint": "Consider adding data-action, targets, or values to make the controller useful",
118
+ "where": "app/views/home/index.html.erb:7 <div data-controller=...>"
119
+ },
120
+ {
121
+ "level": "warn",
122
+ "title": "Suspicious action format",
123
+ "detail": "Action 'invalid-format' doesn't match expected 'event->controller#method' format",
124
+ "hint": "Expected format: 'click->controller#method'",
125
+ "where": "app/views/home/index.html.erb:8 <span data-action=\"in...>"
126
+ },
127
+ {
128
+ "level": "info",
129
+ "title": "Empty binding",
130
+ "detail": "Element has data-controller but no actions, targets, or values",
131
+ "hint": "Consider adding data-action, targets, or values to make the controller useful",
132
+ "where": "app/views/home/index.html.erb:11 <div data-controller=...>"
133
+ }
134
+ ]
135
+ }
@@ -0,0 +1,10 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["name", "output"]
5
+ static values = { name: String }
6
+
7
+ greet() {
8
+ this.outputTarget.textContent = `Hello, ${this.nameValue || this.nameTarget.value}!`
9
+ }
10
+ }
@@ -0,0 +1,13 @@
1
+ <div data-controller="hello" data-hello-name-value="World">
2
+ <input data-hello-target="name" type="text" placeholder="Enter your name">
3
+ <button data-action="click->hello#greet">Greet</button>
4
+ <p data-hello-target="output"></p>
5
+ </div>
6
+
7
+ <div data-controller="unknown-controller">
8
+ <span data-action="invalid-format">Bad action</span>
9
+ </div>
10
+
11
+ <div data-controller="hello">
12
+ <!-- Empty binding - no actions, targets, or values -->
13
+ </div>
data/exe/stimulus-viz ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/stimulus_viz"
5
+
6
+ StimulusViz::CLI.start(ARGV)
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module StimulusViz
7
+ class CLI < Thor
8
+ desc "scan [OPTIONS]", "Scan Rails project for Stimulus controllers and bindings"
9
+ option :root, type: :string, default: ".", desc: "Root path of Rails project"
10
+ option :out, type: :string, default: ".stimulus-viz.json", desc: "Output file path"
11
+ def scan
12
+ root_path = File.expand_path(options[:root])
13
+ output_path = File.expand_path(options[:out])
14
+
15
+ scanner = Scanner.new(root: root_path)
16
+ result = scanner.run
17
+
18
+ File.write(output_path, JSON.pretty_generate(result))
19
+ puts "Scan completed. Results saved to #{output_path}"
20
+ end
21
+
22
+ desc "list [OPTIONS]", "List controllers from cache"
23
+ option :cache, type: :string, default: ".stimulus-viz.json", desc: "Cache file path"
24
+ def list
25
+ data = load_cache(options[:cache])
26
+
27
+ puts "Controllers:"
28
+ data[:controllers].each do |controller|
29
+ puts " #{controller[:name]} (#{controller[:module]})"
30
+ puts " Elements: #{controller[:elements]}"
31
+ puts " Actions: #{controller[:actions].join(', ')}" unless controller[:actions].empty?
32
+ puts " Targets: #{controller[:targets].join(', ')}" unless controller[:targets].empty?
33
+ puts " Values: #{controller[:values].join(', ')}" unless controller[:values].empty?
34
+ puts
35
+ end
36
+ end
37
+
38
+ desc "bindings [OPTIONS]", "Show DOM bindings"
39
+ option :cache, type: :string, default: ".stimulus-viz.json", desc: "Cache file path"
40
+ option :controller, type: :string, desc: "Filter by controller name"
41
+ def bindings
42
+ data = load_cache(options[:cache])
43
+
44
+ bindings = data[:bindings]
45
+ if options[:controller]
46
+ bindings = bindings.select { |b| b[:controllers].include?(options[:controller]) }
47
+ end
48
+
49
+ puts "Bindings:"
50
+ bindings.each do |binding|
51
+ puts " #{binding[:id]} - #{binding[:selector]}"
52
+ puts " Controllers: #{binding[:controllers].join(', ')}" unless binding[:controllers].empty?
53
+ puts " Actions: #{binding[:actions].join(', ')}" unless binding[:actions].empty?
54
+ puts " Targets: #{binding[:targets].map { |t| "#{t[:controller]}.#{t[:name]}" }.join(', ')}" unless binding[:targets].empty?
55
+ puts " Values: #{binding[:values].map { |v| "#{v[:controller]}.#{v[:name]}=#{v[:value]}" }.join(', ')}" unless binding[:values].empty?
56
+ puts " ⚠️ BROKEN" if binding[:broken]
57
+ puts
58
+ end
59
+ end
60
+
61
+ desc "lint [OPTIONS]", "Run lint checks"
62
+ option :cache, type: :string, default: ".stimulus-viz.json", desc: "Cache file path"
63
+ option :fail_on, type: :string, default: "none", desc: "Fail on level: info|warn|error|none"
64
+ def lint
65
+ data = load_cache(options[:cache])
66
+
67
+ puts "Lint Results:"
68
+ exit_code = 0
69
+ fail_levels = %w[info warn error]
70
+ fail_threshold = fail_levels.index(options[:fail_on]) || -1
71
+
72
+ data[:lint].each do |issue|
73
+ level_index = fail_levels.index(issue[:level]) || -1
74
+ exit_code = 1 if level_index >= fail_threshold && fail_threshold >= 0
75
+
76
+ puts " [#{issue[:level].upcase}] #{issue[:title]}"
77
+ puts " #{issue[:detail]}"
78
+ puts " Hint: #{issue[:hint]}" if issue[:hint]
79
+ puts " Location: #{issue[:where]}" if issue[:where]
80
+ puts
81
+ end
82
+
83
+ exit(exit_code) if exit_code > 0
84
+ end
85
+
86
+ desc "export [OPTIONS]", "Export data in different formats"
87
+ option :cache, type: :string, default: ".stimulus-viz.json", desc: "Cache file path"
88
+ option :format, type: :string, required: true, desc: "Output format: json|dot"
89
+ option :out, type: :string, required: true, desc: "Output file path"
90
+ def export
91
+ data = load_cache(options[:cache])
92
+
93
+ case options[:format]
94
+ when "json"
95
+ File.write(options[:out], JSON.pretty_generate(data))
96
+ when "dot"
97
+ dot_content = generate_dot(data)
98
+ File.write(options[:out], dot_content)
99
+ else
100
+ puts "Unknown format: #{options[:format]}"
101
+ exit(1)
102
+ end
103
+
104
+ puts "Exported to #{options[:out]} in #{options[:format]} format"
105
+ end
106
+
107
+ private
108
+
109
+ def load_cache(cache_path)
110
+ unless File.exist?(cache_path)
111
+ puts "Cache file not found: #{cache_path}"
112
+ puts "Run 'stimulus-viz scan' first"
113
+ exit(1)
114
+ end
115
+
116
+ JSON.parse(File.read(cache_path), symbolize_names: true)
117
+ end
118
+
119
+ def generate_dot(data)
120
+ lines = ["digraph stimulus {"]
121
+ lines << " rankdir=LR;"
122
+ lines << " node [shape=box];"
123
+
124
+ # Add controller nodes
125
+ data[:controllers].each do |controller|
126
+ lines << " \"#{controller[:name]}\" [label=\"#{controller[:name]}\\n(#{controller[:elements]} elements)\"];"
127
+ end
128
+
129
+ # Add binding edges
130
+ data[:bindings].each do |binding|
131
+ binding[:controllers].each do |controller|
132
+ lines << " \"#{binding[:selector]}\" -> \"#{controller}\" [label=\"data-controller\"];"
133
+ end
134
+
135
+ binding[:actions].each do |action|
136
+ if action =~ /^(.+)->(.+)#(.+)$/
137
+ event, controller_method = $1, $2
138
+ lines << " \"#{event}\" -> \"#{controller_method}\" [label=\"#{action}\"];"
139
+ end
140
+ end
141
+ end
142
+
143
+ lines << "}"
144
+ lines.join("\n")
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module StimulusViz
7
+ class Scanner
8
+ attr_reader :root
9
+
10
+ def initialize(root:)
11
+ @root = root
12
+ @controllers = []
13
+ @bindings = []
14
+ @binding_counter = 0
15
+ end
16
+
17
+ def run
18
+ scan_controllers
19
+ scan_erb_files
20
+ generate_lint_results
21
+
22
+ {
23
+ meta: {
24
+ root: @root,
25
+ generated_at: Time.now.iso8601
26
+ },
27
+ controllers: aggregate_controllers,
28
+ bindings: @bindings,
29
+ lint: @lint_results
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def scan_controllers
36
+ controller_pattern = File.join(@root, "app/javascript/controllers/**/*_controller.{js,ts}")
37
+ Dir.glob(controller_pattern).each do |file_path|
38
+ relative_path = file_path.sub(@root + "/", "")
39
+ basename = File.basename(file_path, ".*")
40
+
41
+ # Convert filename to controller name: message_form_controller -> message-form
42
+ controller_name = basename.sub(/_controller$/, "").tr("_", "-")
43
+
44
+ @controllers << {
45
+ name: controller_name,
46
+ module: relative_path,
47
+ file_path: file_path
48
+ }
49
+ end
50
+ end
51
+
52
+ def scan_erb_files
53
+ erb_pattern = File.join(@root, "app/views/**/*.erb")
54
+ Dir.glob(erb_pattern).each do |file_path|
55
+ scan_erb_file(file_path)
56
+ end
57
+ end
58
+
59
+ def scan_erb_file(file_path)
60
+ content = File.read(file_path)
61
+ relative_path = file_path.sub(@root + "/", "")
62
+
63
+ # Find HTML tags by parsing character by character to handle quoted attributes
64
+ i = 0
65
+ while i < content.length
66
+ if content[i] == '<' && content[i + 1] =~ /[a-zA-Z]/
67
+ # Found start of tag, find the end
68
+ tag_start = i
69
+ i += 1
70
+ in_quotes = false
71
+ quote_char = nil
72
+
73
+ while i < content.length
74
+ char = content[i]
75
+
76
+ if !in_quotes && (char == '"' || char == "'")
77
+ in_quotes = true
78
+ quote_char = char
79
+ elsif in_quotes && char == quote_char
80
+ in_quotes = false
81
+ quote_char = nil
82
+ elsif !in_quotes && char == '>'
83
+ # Found end of tag
84
+ tag_end = i
85
+ full_tag = content[tag_start..tag_end]
86
+
87
+ if full_tag.include?('data-')
88
+ line_number = content[0...tag_start].count("\n") + 1
89
+ process_element(full_tag, relative_path, line_number)
90
+ end
91
+ break
92
+ end
93
+
94
+ i += 1
95
+ end
96
+ end
97
+ i += 1
98
+ end
99
+ end
100
+
101
+ def process_element(element, file_path, line_number)
102
+ @binding_counter += 1
103
+ binding_id = "el_%04d" % @binding_counter
104
+
105
+ # Extract id attribute for better selector
106
+ id_match = element.match(/id=["']([^"']+)["']/)
107
+ selector_suffix = id_match ? "##{id_match[1]}" : ""
108
+ selector = "#{file_path}:#{line_number} <#{element[1..20]}...#{selector_suffix}>"
109
+
110
+ binding = {
111
+ id: binding_id,
112
+ selector: selector,
113
+ controllers: extract_controllers(element),
114
+ actions: extract_actions(element),
115
+ targets: extract_targets(element),
116
+ values: extract_values(element)
117
+ }
118
+
119
+ # Mark as broken if has controllers but no interactions
120
+ if !binding[:controllers].empty? &&
121
+ binding[:actions].empty? &&
122
+ binding[:targets].empty? &&
123
+ binding[:values].empty?
124
+ binding[:broken] = true
125
+ end
126
+
127
+ @bindings << binding
128
+ end
129
+
130
+ def extract_controllers(element)
131
+ match = element.match(/data-controller=["']([^"']+)["']/m)
132
+ return [] unless match
133
+
134
+ match[1].split(/\s+/).reject(&:empty?)
135
+ end
136
+
137
+ def extract_actions(element)
138
+ match = element.match(/data-action=["']([^"']+)["']/m)
139
+ return [] unless match
140
+
141
+ match[1].split(/\s+/).reject(&:empty?)
142
+ end
143
+
144
+ def extract_targets(element)
145
+ targets = []
146
+
147
+ # Single target: data-controller-target="name"
148
+ element.scan(/data-([^-\s]+)-target=["']([^"']+)["']/m) do |controller, name|
149
+ targets << {
150
+ controller: controller.tr("_", "-"),
151
+ name: name,
152
+ selector: "(static)"
153
+ }
154
+ end
155
+
156
+ # Multiple targets: data-controller-targets="name1 name2"
157
+ element.scan(/data-([^-\s]+)-targets=["']([^"']+)["']/m) do |controller, names|
158
+ names.split(/\s+/).each do |name|
159
+ targets << {
160
+ controller: controller.tr("_", "-"),
161
+ name: name,
162
+ selector: "(static)"
163
+ }
164
+ end
165
+ end
166
+
167
+ targets
168
+ end
169
+
170
+ def extract_values(element)
171
+ values = []
172
+
173
+ element.scan(/data-([^-\s]+)-([^-\s]+(?:-[^-\s]+)*)-value=["']([^"']+)["']/m) do |controller, value_name, value|
174
+ # Convert kebab-case to camelCase
175
+ camel_name = value_name.split("-").map.with_index do |part, index|
176
+ index == 0 ? part : part.capitalize
177
+ end.join
178
+
179
+ values << {
180
+ controller: controller.tr("_", "-"),
181
+ name: camel_name,
182
+ value: value
183
+ }
184
+ end
185
+
186
+ values
187
+ end
188
+
189
+ def aggregate_controllers
190
+ @controllers.map do |controller|
191
+ # Count elements that use this controller
192
+ elements_count = @bindings.count { |b| b[:controllers].include?(controller[:name]) }
193
+
194
+ # Collect unique actions, targets, values for this controller
195
+ actions = []
196
+ targets = []
197
+ values = []
198
+
199
+ @bindings.each do |binding|
200
+ next unless binding[:controllers].include?(controller[:name])
201
+
202
+ # Extract actions for this controller
203
+ binding[:actions].each do |action|
204
+ if action =~ /^(.+)->([^#]+)#(.+)$/
205
+ _event, ctrl, method = $1, $2, $3
206
+ actions << method if ctrl == controller[:name]
207
+ end
208
+ end
209
+
210
+ # Extract targets for this controller
211
+ binding[:targets].each do |target|
212
+ targets << target[:name] if target[:controller] == controller[:name]
213
+ end
214
+
215
+ # Extract values for this controller
216
+ binding[:values].each do |value|
217
+ values << value[:name] if value[:controller] == controller[:name]
218
+ end
219
+ end
220
+
221
+ {
222
+ name: controller[:name],
223
+ module: controller[:module],
224
+ elements: elements_count,
225
+ actions: actions.uniq.sort,
226
+ targets: targets.uniq.sort,
227
+ values: values.uniq.sort
228
+ }
229
+ end
230
+ end
231
+
232
+ def generate_lint_results
233
+ @lint_results = []
234
+
235
+ controller_names = @controllers.map { |c| c[:name] }
236
+
237
+ @bindings.each do |binding|
238
+ # Check for unknown controllers
239
+ binding[:controllers].each do |controller|
240
+ unless controller_names.include?(controller)
241
+ @lint_results << {
242
+ level: "warn",
243
+ title: "Unknown controller",
244
+ detail: "Controller '#{controller}' is referenced but not found in controllers directory",
245
+ where: binding[:selector]
246
+ }
247
+ end
248
+ end
249
+
250
+ # Check for suspicious actions
251
+ binding[:actions].each do |action|
252
+ unless action =~ /^[^->]+->[^#]+#[^#]+$/
253
+ @lint_results << {
254
+ level: "warn",
255
+ title: "Suspicious action format",
256
+ detail: "Action '#{action}' doesn't match expected 'event->controller#method' format",
257
+ hint: "Expected format: 'click->controller#method'",
258
+ where: binding[:selector]
259
+ }
260
+ end
261
+ end
262
+
263
+ # Check for empty bindings
264
+ if !binding[:controllers].empty? &&
265
+ binding[:actions].empty? &&
266
+ binding[:targets].empty? &&
267
+ binding[:values].empty?
268
+ @lint_results << {
269
+ level: "info",
270
+ title: "Empty binding",
271
+ detail: "Element has data-controller but no actions, targets, or values",
272
+ hint: "Consider adding data-action, targets, or values to make the controller useful",
273
+ where: binding[:selector]
274
+ }
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusViz
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stimulus_viz/version"
4
+ require_relative "stimulus_viz/scanner"
5
+ require_relative "stimulus_viz/cli"
6
+
7
+ module StimulusViz
8
+ class Error < StandardError; end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/stimulus_viz/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "stimulus-viz"
7
+ spec.version = StimulusViz::VERSION
8
+ spec.authors = ["Minori Sugimura"]
9
+ spec.email = ["minorex.0117@gmail.com"]
10
+
11
+ spec.summary = "Rails/Hotwire Stimulus visualization tool"
12
+ spec.description = "Static analysis tool for Stimulus controllers and DOM bindings in Rails applications"
13
+ spec.homepage = "https://github.com/Minokiti11/stimulus-viz"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.1.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/CHANGELOG.md"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (File.expand_path(f) == __FILE__) ||
24
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
25
+ end
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency "thor", "~> 1.0"
32
+ spec.add_dependency "json", "~> 2.0"
33
+
34
+ spec.add_development_dependency "minitest", "~> 5.0"
35
+ spec.add_development_dependency "rake", "~> 13.0"
36
+ end
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stimulus-viz
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
- - Developer
7
+ - Minori Sugimura
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -68,18 +68,32 @@ dependencies:
68
68
  description: Static analysis tool for Stimulus controllers and DOM bindings in Rails
69
69
  applications
70
70
  email:
71
- - developer@example.com
72
- executables: []
71
+ - minorex.0117@gmail.com
72
+ executables:
73
+ - stimulus-viz
73
74
  extensions: []
74
75
  extra_rdoc_files: []
75
- files: []
76
- homepage: https://github.com/example/stimulus-viz
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE.txt
79
+ - README.md
80
+ - Rakefile
81
+ - example.json
82
+ - example_project/app/javascript/controllers/hello_controller.js
83
+ - example_project/app/views/home/index.html.erb
84
+ - exe/stimulus-viz
85
+ - lib/stimulus_viz.rb
86
+ - lib/stimulus_viz/cli.rb
87
+ - lib/stimulus_viz/scanner.rb
88
+ - lib/stimulus_viz/version.rb
89
+ - stimulus-viz.gemspec
90
+ homepage: https://github.com/Minokiti11/stimulus-viz
77
91
  licenses:
78
92
  - MIT
79
93
  metadata:
80
- homepage_uri: https://github.com/example/stimulus-viz
81
- source_code_uri: https://github.com/example/stimulus-viz
82
- changelog_uri: https://github.com/example/stimulus-viz/CHANGELOG.md
94
+ homepage_uri: https://github.com/Minokiti11/stimulus-viz
95
+ source_code_uri: https://github.com/Minokiti11/stimulus-viz
96
+ changelog_uri: https://github.com/Minokiti11/stimulus-viz/CHANGELOG.md
83
97
  rdoc_options: []
84
98
  require_paths:
85
99
  - lib
@@ -94,7 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
108
  - !ruby/object:Gem::Version
95
109
  version: '0'
96
110
  requirements: []
97
- rubygems_version: 3.6.9
111
+ rubygems_version: 4.0.3
98
112
  specification_version: 4
99
113
  summary: Rails/Hotwire Stimulus visualization tool
100
114
  test_files: []