rails-ai-context 0.15.7 → 0.15.8
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/CHANGELOG.md +6 -0
- data/lib/rails_ai_context/tools/validate.rb +651 -98
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0d532a0b5cd13d8f15fd988426013b6c493df52bbea861601bc6e98fc50284d
|
|
4
|
+
data.tar.gz: d6937138538b5863998da10507a967a3538354bd80885e2d8be29dce9552331f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49df028217e81fd1dc56513dad0ba11cc4b755d231a06bacd822b848e88f30ad37108ba6c8c8a076f36af35ee3babbb159d2dba47162fc34ae109670fecb0107
|
|
7
|
+
data.tar.gz: 977cca79b0eba516d23c4cf8dbc678bc0874420405959fc927150f10cd52c14f554c48e801db3f74849e1919dfe15caea6d8078539436aec88dab1b2838bef8d
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.15.8] - 2026-03-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Semantic validation (`level:"rails"`)** — `rails_validate` now supports `level:"rails"` for deep semantic checks beyond syntax: partial existence, route helper validity, column references vs schema, strong params vs schema columns, callback method existence, route-action consistency, `has_many` dependent options, missing FK indexes, and Stimulus controller file existence.
|
|
13
|
+
|
|
8
14
|
## [0.15.7] - 2026-03-22
|
|
9
15
|
|
|
10
16
|
### Improved
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "erb"
|
|
5
|
+
require "set"
|
|
5
6
|
|
|
6
7
|
module RailsAiContext
|
|
7
8
|
module Tools
|
|
8
9
|
class Validate < BaseTool
|
|
9
10
|
tool_name "rails_validate"
|
|
10
|
-
description "Validate syntax of multiple files at once (Ruby, ERB, JavaScript).
|
|
11
|
+
description "Validate syntax of multiple files at once (Ruby, ERB, JavaScript). " \
|
|
12
|
+
"Replaces separate ruby -c, erb check, and node -c calls. " \
|
|
13
|
+
"Use level:\"rails\" for semantic checks: partial existence, route helpers, " \
|
|
14
|
+
"column references, strong params vs schema, callback method existence, " \
|
|
15
|
+
"route-action consistency, has_many dependent, FK indexes, Stimulus controllers."
|
|
11
16
|
|
|
12
17
|
def self.max_files
|
|
13
18
|
RailsAiContext.configuration.max_validate_files
|
|
@@ -19,6 +24,11 @@ module RailsAiContext
|
|
|
19
24
|
type: "array",
|
|
20
25
|
items: { type: "string" },
|
|
21
26
|
description: "File paths relative to Rails root (e.g. ['app/models/cook.rb', 'app/views/cooks/index.html.erb'])"
|
|
27
|
+
},
|
|
28
|
+
level: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: %w[syntax rails],
|
|
31
|
+
description: "Validation level. syntax: check syntax only (default, fast). rails: syntax + semantic checks (partial existence, route helpers, column references)."
|
|
22
32
|
}
|
|
23
33
|
},
|
|
24
34
|
required: %w[files]
|
|
@@ -26,14 +36,11 @@ module RailsAiContext
|
|
|
26
36
|
|
|
27
37
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
if files.empty?
|
|
31
|
-
return text_response("No files provided.")
|
|
32
|
-
end
|
|
39
|
+
# ── Main entry point ─────────────────────────────────────────────
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
def self.call(files:, level: "syntax", server_context: nil)
|
|
42
|
+
return text_response("No files provided.") if files.empty?
|
|
43
|
+
return text_response("Too many files (#{files.size}). Maximum is #{max_files} per call.") if files.size > max_files
|
|
37
44
|
|
|
38
45
|
results = []
|
|
39
46
|
passed = 0
|
|
@@ -42,7 +49,6 @@ module RailsAiContext
|
|
|
42
49
|
files.each do |file|
|
|
43
50
|
full_path = Rails.root.join(file)
|
|
44
51
|
|
|
45
|
-
# Path traversal protection
|
|
46
52
|
unless File.exist?(full_path)
|
|
47
53
|
results << "\u2717 #{file} \u2014 file not found"
|
|
48
54
|
total += 1
|
|
@@ -64,12 +70,12 @@ module RailsAiContext
|
|
|
64
70
|
|
|
65
71
|
total += 1
|
|
66
72
|
|
|
67
|
-
if file.end_with?(".rb")
|
|
68
|
-
|
|
73
|
+
ok, msg, warnings = if file.end_with?(".rb")
|
|
74
|
+
validate_ruby(full_path)
|
|
69
75
|
elsif file.end_with?(".html.erb") || file.end_with?(".erb")
|
|
70
|
-
|
|
76
|
+
validate_erb(full_path)
|
|
71
77
|
elsif file.end_with?(".js")
|
|
72
|
-
|
|
78
|
+
validate_javascript(full_path)
|
|
73
79
|
else
|
|
74
80
|
results << "- #{file} \u2014 skipped (unsupported file type)"
|
|
75
81
|
total -= 1
|
|
@@ -82,161 +88,708 @@ module RailsAiContext
|
|
|
82
88
|
else
|
|
83
89
|
results << "\u2717 #{file} \u2014 #{msg}"
|
|
84
90
|
end
|
|
91
|
+
|
|
92
|
+
(warnings || []).each { |w| results << " \u26A0 #{w}" }
|
|
93
|
+
|
|
94
|
+
if level == "rails" && ok
|
|
95
|
+
rails_warnings = check_rails_semantics(file, full_path)
|
|
96
|
+
rails_warnings.each { |w| results << " \u26A0 #{w}" }
|
|
97
|
+
end
|
|
85
98
|
end
|
|
86
99
|
|
|
87
100
|
output = results.join("\n")
|
|
88
101
|
output += "\n\n#{passed}/#{total} files passed"
|
|
89
|
-
|
|
90
102
|
text_response(output)
|
|
91
103
|
end
|
|
92
104
|
|
|
93
|
-
#
|
|
105
|
+
# ── Prism detection ──────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
private_class_method def self.prism_available?
|
|
108
|
+
return @prism_available unless @prism_available.nil?
|
|
109
|
+
|
|
110
|
+
@prism_available = begin
|
|
111
|
+
require "prism"
|
|
112
|
+
true
|
|
113
|
+
rescue LoadError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── Ruby validation ──────────────────────────────────────────────
|
|
119
|
+
|
|
94
120
|
private_class_method def self.validate_ruby(full_path)
|
|
121
|
+
prism_available? ? validate_ruby_prism(full_path) : validate_ruby_subprocess(full_path)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private_class_method def self.validate_ruby_prism(full_path)
|
|
125
|
+
result = Prism.parse_file(full_path.to_s)
|
|
126
|
+
basename = File.basename(full_path.to_s)
|
|
127
|
+
warnings = result.warnings.map do |w|
|
|
128
|
+
"#{basename}:#{w.location.start_line}:#{w.location.start_column}: warning: #{w.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if result.success?
|
|
132
|
+
[ true, nil, warnings ]
|
|
133
|
+
else
|
|
134
|
+
errors = result.errors.first(5).map do |e|
|
|
135
|
+
"#{basename}:#{e.location.start_line}:#{e.location.start_column}: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
[ false, errors.join("\n"), warnings ]
|
|
138
|
+
end
|
|
139
|
+
rescue => _e
|
|
140
|
+
validate_ruby_subprocess(full_path)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method def self.validate_ruby_subprocess(full_path)
|
|
95
144
|
result, status = Open3.capture2e("ruby", "-c", full_path.to_s)
|
|
96
145
|
if status.success?
|
|
97
|
-
[ true, nil ]
|
|
146
|
+
[ true, nil, [] ]
|
|
98
147
|
else
|
|
99
|
-
# Show up to 5 non-empty error lines for full context (ruby -c gives multi-line errors)
|
|
100
148
|
error_lines = result.lines
|
|
101
149
|
.reject { |l| l.strip.empty? || l.include?("Syntax OK") }
|
|
102
150
|
.first(5)
|
|
103
151
|
.map { |l| l.strip.sub(full_path.to_s, File.basename(full_path.to_s)) }
|
|
104
|
-
|
|
105
|
-
[ false, error ]
|
|
152
|
+
[ false, error_lines.any? ? error_lines.join("\n") : "syntax error", [] ]
|
|
106
153
|
end
|
|
107
154
|
end
|
|
108
155
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
#
|
|
112
|
-
# Two key pre-processing steps avoid false positives:
|
|
113
|
-
# 1. Convert `<%= ... %>` to `<% ... %>` — prevents the `_buf << ( helper do ).to_s`
|
|
114
|
-
# ambiguity that standard ERB compilation creates for block-form helpers
|
|
115
|
-
# like `<%= link_to ... do %>`, `<%= form_with ... do |f| %>`, etc.
|
|
116
|
-
# 2. Wrap compiled source in a method def — makes `yield` syntactically valid.
|
|
156
|
+
# ── ERB validation ───────────────────────────────────────────────
|
|
157
|
+
|
|
117
158
|
private_class_method def self.validate_erb(full_path)
|
|
118
|
-
return [ false, "file too large" ] if File.size(full_path) > RailsAiContext.configuration.max_file_size
|
|
159
|
+
return [ false, "file too large", [] ] if File.size(full_path) > RailsAiContext.configuration.max_file_size
|
|
119
160
|
|
|
120
161
|
content = File.binread(full_path).force_encoding("UTF-8")
|
|
121
|
-
|
|
122
|
-
# Pre-process: convert output tags to non-output for syntax-only checking.
|
|
123
|
-
# This is safe because we only check structure (do/end, if/end matching),
|
|
124
|
-
# not whether output is correct.
|
|
125
162
|
processed = content.gsub("<%=", "<%")
|
|
126
163
|
|
|
127
|
-
# Compile ERB to Ruby, wrapped in a method so `yield` is valid syntax.
|
|
128
|
-
# Force UTF-8 on .src output — ERB may return ASCII-8BIT which breaks
|
|
129
|
-
# concatenation with UTF-8 strings when non-ASCII bytes (emoji, etc.) are present.
|
|
130
164
|
erb_src = +ERB.new(processed).src
|
|
131
165
|
erb_src.force_encoding("UTF-8")
|
|
132
166
|
compiled = "# encoding: utf-8\ndef __erb_syntax_check\n#{erb_src}\nend"
|
|
133
167
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
168
|
+
if prism_available?
|
|
169
|
+
result = Prism.parse(compiled)
|
|
170
|
+
if result.success?
|
|
171
|
+
[ true, nil, [] ]
|
|
172
|
+
else
|
|
173
|
+
error = result.errors.first(5).map do |e|
|
|
174
|
+
"line #{[ e.location.start_line - 2, 1 ].max}: #{e.message}"
|
|
175
|
+
end.join("\n")
|
|
176
|
+
[ false, error, [] ]
|
|
177
|
+
end
|
|
137
178
|
else
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
179
|
+
check_result, check_status = Open3.capture2e("ruby", "-c", "-", stdin_data: compiled)
|
|
180
|
+
if check_status.success?
|
|
181
|
+
[ true, nil, [] ]
|
|
182
|
+
else
|
|
183
|
+
error = check_result.lines
|
|
184
|
+
.reject { |l| l.strip.empty? || l.include?("Syntax OK") }
|
|
185
|
+
.first(5)
|
|
186
|
+
.map { |l| l.strip.sub(/-:(\d+):/) { "ruby: -:#{$1.to_i - 2}:" } }
|
|
187
|
+
[ false, error.any? ? error.join("\n") : "ERB syntax error", [] ]
|
|
188
|
+
end
|
|
145
189
|
end
|
|
146
190
|
rescue => e
|
|
147
|
-
[ false, "ERB check error: #{e.message}" ]
|
|
191
|
+
[ false, "ERB check error: #{e.message}", [] ]
|
|
148
192
|
end
|
|
149
193
|
|
|
150
|
-
#
|
|
194
|
+
# ── JavaScript validation ────────────────────────────────────────
|
|
195
|
+
|
|
151
196
|
private_class_method def self.validate_javascript(full_path)
|
|
152
197
|
@node_available = system("which", "node", out: File::NULL, err: File::NULL) if @node_available.nil?
|
|
153
198
|
|
|
154
199
|
if @node_available
|
|
155
200
|
result, status = Open3.capture2e("node", "-c", full_path.to_s)
|
|
156
201
|
if status.success?
|
|
157
|
-
[ true, nil ]
|
|
202
|
+
[ true, nil, [] ]
|
|
158
203
|
else
|
|
159
|
-
|
|
160
|
-
error_lines = result.lines
|
|
161
|
-
.reject { |l| l.strip.empty? }
|
|
162
|
-
.first(3)
|
|
204
|
+
error_lines = result.lines.reject { |l| l.strip.empty? }.first(3)
|
|
163
205
|
.map { |l| l.strip.sub(full_path.to_s, File.basename(full_path.to_s)) }
|
|
164
|
-
|
|
165
|
-
[ false, error ]
|
|
206
|
+
[ false, error_lines.any? ? error_lines.join("\n") : "syntax error", [] ]
|
|
166
207
|
end
|
|
167
208
|
else
|
|
168
209
|
validate_javascript_fallback(full_path)
|
|
169
210
|
end
|
|
170
211
|
end
|
|
171
212
|
|
|
172
|
-
# Basic JavaScript validation when node is not available.
|
|
173
|
-
# Checks for unmatched braces, brackets, and parentheses.
|
|
174
213
|
private_class_method def self.validate_javascript_fallback(full_path)
|
|
175
|
-
return [ false, "file too large for basic validation" ] if File.size(full_path) > RailsAiContext.configuration.max_file_size
|
|
214
|
+
return [ false, "file too large for basic validation", [] ] if File.size(full_path) > RailsAiContext.configuration.max_file_size
|
|
176
215
|
content = File.read(full_path)
|
|
177
216
|
stack = []
|
|
178
217
|
openers = { "{" => "}", "[" => "]", "(" => ")" }
|
|
179
218
|
closers = { "}" => "{", "]" => "[", ")" => "(" }
|
|
180
|
-
in_string = nil
|
|
181
|
-
in_line_comment = false
|
|
182
|
-
in_block_comment = false
|
|
183
|
-
prev_char = nil
|
|
219
|
+
in_string = nil; in_line_comment = false; in_block_comment = false; prev_char = nil
|
|
184
220
|
|
|
185
221
|
content.each_char.with_index do |char, i|
|
|
186
|
-
if in_line_comment
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
next
|
|
190
|
-
end
|
|
222
|
+
if in_line_comment then (in_line_comment = false if char == "\n"); prev_char = char; next end
|
|
223
|
+
if in_block_comment then (in_block_comment = false if prev_char == "*" && char == "/"); prev_char = char; next end
|
|
224
|
+
if in_string then (in_string = nil if char == in_string && prev_char != "\\"); prev_char = char; next end
|
|
191
225
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
226
|
+
case char
|
|
227
|
+
when '"', "'", "`" then in_string = char
|
|
228
|
+
when "/" then (in_line_comment = true; stack.pop if stack.last == "/") if prev_char == "/"
|
|
229
|
+
when "*" then in_block_comment = true if prev_char == "/"
|
|
230
|
+
else
|
|
231
|
+
if openers.key?(char) then stack << char
|
|
232
|
+
elsif closers.key?(char)
|
|
233
|
+
return [ false, "line #{content[0..i].count("\n") + 1}: unmatched '#{char}'", [] ] if stack.empty? || stack.last != closers[char]
|
|
234
|
+
stack.pop
|
|
195
235
|
end
|
|
196
|
-
prev_char = char
|
|
197
|
-
next
|
|
198
236
|
end
|
|
237
|
+
prev_char = char
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
stack.empty? ? [ true, nil, [] ] : [ false, "unmatched '#{stack.last}' (node not available, basic check only)", [] ]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# ════════════════════════════════════════════════════════════════════
|
|
244
|
+
# ── Rails-aware semantic checks (level: "rails") ─────────────────
|
|
245
|
+
# ════════════════════════════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
# Prism AST Visitor — walks the AST once, extracts data for all checks
|
|
248
|
+
class RailsSemanticVisitor < Prism::Visitor
|
|
249
|
+
attr_reader :render_calls, :route_helper_calls, :validates_calls,
|
|
250
|
+
:permit_calls, :callback_registrations, :has_many_calls
|
|
199
251
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
252
|
+
CALLBACK_NAMES = %i[
|
|
253
|
+
before_validation after_validation before_save after_save
|
|
254
|
+
before_create after_create before_update after_update
|
|
255
|
+
before_destroy after_destroy after_commit after_rollback
|
|
256
|
+
].to_set.freeze
|
|
257
|
+
|
|
258
|
+
def initialize
|
|
259
|
+
super
|
|
260
|
+
@render_calls = []
|
|
261
|
+
@route_helper_calls = []
|
|
262
|
+
@validates_calls = []
|
|
263
|
+
@permit_calls = []
|
|
264
|
+
@callback_registrations = []
|
|
265
|
+
@has_many_calls = []
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def visit_call_node(node)
|
|
269
|
+
case node.name
|
|
270
|
+
when :render then extract_render(node)
|
|
271
|
+
when :validates then extract_validates(node)
|
|
272
|
+
when :permit then extract_permit(node)
|
|
273
|
+
when :has_many then extract_has_many(node)
|
|
274
|
+
else
|
|
275
|
+
if node.name.to_s.end_with?("_path", "_url") && node.receiver.nil?
|
|
276
|
+
@route_helper_calls << { name: node.name.to_s, line: node.location.start_line }
|
|
277
|
+
elsif CALLBACK_NAMES.include?(node.name) && node.receiver.nil?
|
|
278
|
+
extract_callback(node)
|
|
203
279
|
end
|
|
204
|
-
prev_char = char
|
|
205
|
-
next
|
|
206
280
|
end
|
|
281
|
+
super
|
|
282
|
+
end
|
|
207
283
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
def extract_render(node)
|
|
287
|
+
args = node.arguments&.arguments || []
|
|
288
|
+
args.each do |arg|
|
|
289
|
+
case arg
|
|
290
|
+
when Prism::StringNode
|
|
291
|
+
@render_calls << { name: arg.unescaped, line: node.location.start_line }
|
|
292
|
+
when Prism::KeywordHashNode
|
|
293
|
+
arg.elements.each do |elem|
|
|
294
|
+
next unless elem.is_a?(Prism::AssocNode)
|
|
295
|
+
key = elem.key
|
|
296
|
+
val = elem.value
|
|
297
|
+
if key.is_a?(Prism::SymbolNode) && key.value == "partial" && val.is_a?(Prism::StringNode)
|
|
298
|
+
@render_calls << { name: val.unescaped, line: node.location.start_line }
|
|
299
|
+
end
|
|
300
|
+
end
|
|
215
301
|
end
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def extract_validates(node)
|
|
306
|
+
args = node.arguments&.arguments || []
|
|
307
|
+
columns = []
|
|
308
|
+
args.each do |arg|
|
|
309
|
+
break unless arg.is_a?(Prism::SymbolNode)
|
|
310
|
+
columns << arg.value
|
|
311
|
+
end
|
|
312
|
+
@validates_calls << { columns: columns, line: node.location.start_line } if columns.any?
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def extract_permit(node)
|
|
316
|
+
args = node.arguments&.arguments || []
|
|
317
|
+
params = []
|
|
318
|
+
args.each do |arg|
|
|
319
|
+
case arg
|
|
320
|
+
when Prism::SymbolNode then params << arg.value
|
|
219
321
|
end
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
322
|
+
end
|
|
323
|
+
@permit_calls << { params: params, line: node.location.start_line } if params.any?
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def extract_has_many(node)
|
|
327
|
+
args = node.arguments&.arguments || []
|
|
328
|
+
name = nil
|
|
329
|
+
has_dependent = false
|
|
330
|
+
args.each do |arg|
|
|
331
|
+
case arg
|
|
332
|
+
when Prism::SymbolNode
|
|
333
|
+
name ||= arg.value
|
|
334
|
+
when Prism::KeywordHashNode
|
|
335
|
+
arg.elements.each do |elem|
|
|
336
|
+
next unless elem.is_a?(Prism::AssocNode) && elem.key.is_a?(Prism::SymbolNode)
|
|
337
|
+
has_dependent = true if elem.key.value == "dependent"
|
|
227
338
|
end
|
|
228
|
-
stack.pop
|
|
229
339
|
end
|
|
230
340
|
end
|
|
341
|
+
@has_many_calls << { name: name, has_dependent: has_dependent, line: node.location.start_line } if name
|
|
342
|
+
end
|
|
231
343
|
|
|
232
|
-
|
|
344
|
+
def extract_callback(node)
|
|
345
|
+
args = node.arguments&.arguments || []
|
|
346
|
+
methods = args.select { |a| a.is_a?(Prism::SymbolNode) }.map(&:value)
|
|
347
|
+
@callback_registrations << { type: node.name.to_s, methods: methods, line: node.location.start_line } if methods.any?
|
|
348
|
+
end
|
|
349
|
+
end if defined?(Prism)
|
|
350
|
+
|
|
351
|
+
# ── Semantic check dispatcher ────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
private_class_method def self.check_rails_semantics(file, full_path)
|
|
354
|
+
warnings = []
|
|
355
|
+
|
|
356
|
+
context = begin; cached_context; rescue; return warnings; end
|
|
357
|
+
return warnings unless context
|
|
358
|
+
|
|
359
|
+
content = File.read(full_path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue nil
|
|
360
|
+
return warnings unless content
|
|
361
|
+
|
|
362
|
+
# Parse with Prism AST visitor (single pass for all checks)
|
|
363
|
+
visitor = parse_and_visit(file, content)
|
|
364
|
+
|
|
365
|
+
if file.end_with?(".html.erb", ".erb")
|
|
366
|
+
if visitor
|
|
367
|
+
warnings.concat(check_partial_existence_ast(file, visitor))
|
|
368
|
+
warnings.concat(check_route_helpers_ast(visitor, context))
|
|
369
|
+
else
|
|
370
|
+
warnings.concat(check_partial_existence_regex(file, content))
|
|
371
|
+
warnings.concat(check_route_helpers_regex(content, context))
|
|
372
|
+
end
|
|
373
|
+
warnings.concat(check_stimulus_controllers(content, context))
|
|
374
|
+
elsif file.end_with?(".rb")
|
|
375
|
+
if visitor
|
|
376
|
+
warnings.concat(check_route_helpers_ast(visitor, context))
|
|
377
|
+
warnings.concat(check_column_references_ast(file, visitor, context))
|
|
378
|
+
warnings.concat(check_strong_params_ast(file, visitor, context))
|
|
379
|
+
warnings.concat(check_callback_existence_ast(file, visitor, context))
|
|
380
|
+
else
|
|
381
|
+
warnings.concat(check_route_helpers_regex(content, context))
|
|
382
|
+
warnings.concat(check_column_references_regex(file, content, context))
|
|
383
|
+
end
|
|
384
|
+
# Cache-only checks (no AST needed)
|
|
385
|
+
warnings.concat(check_has_many_dependent(file, context))
|
|
386
|
+
warnings.concat(check_missing_fk_index(file, context))
|
|
387
|
+
warnings.concat(check_route_action_consistency(file, context))
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
warnings
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
private_class_method def self.parse_and_visit(file, content)
|
|
394
|
+
return nil unless prism_available?
|
|
395
|
+
|
|
396
|
+
source = if file.end_with?(".html.erb", ".erb")
|
|
397
|
+
processed = content.gsub("<%=", "<%")
|
|
398
|
+
erb_src = +ERB.new(processed).src
|
|
399
|
+
erb_src.force_encoding("UTF-8")
|
|
400
|
+
"# encoding: utf-8\n#{erb_src}"
|
|
401
|
+
else
|
|
402
|
+
content
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
result = Prism.parse(source)
|
|
406
|
+
visitor = RailsSemanticVisitor.new
|
|
407
|
+
result.value.accept(visitor)
|
|
408
|
+
visitor
|
|
409
|
+
rescue
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# ── CHECK 1: Partial existence (AST) ─────────────────────────────
|
|
414
|
+
|
|
415
|
+
private_class_method def self.check_partial_existence_ast(file, visitor)
|
|
416
|
+
warnings = []
|
|
417
|
+
visitor.render_calls.each do |rc|
|
|
418
|
+
ref = rc[:name]
|
|
419
|
+
next if ref.include?("@") || ref.include?("#") || ref.include?("{")
|
|
420
|
+
possible = resolve_partial_paths(file, ref)
|
|
421
|
+
unless possible.any? { |p| File.exist?(File.join(Rails.root, "app", "views", p)) }
|
|
422
|
+
warnings << "render \"#{ref}\" \u2014 partial not found"
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
warnings
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Regex fallback for non-Prism environments
|
|
429
|
+
private_class_method def self.check_partial_existence_regex(file, content)
|
|
430
|
+
warnings = []
|
|
431
|
+
content.scan(/render\s+(?:partial:\s*)?["']([^"']+)["']/).flatten.uniq.each do |ref|
|
|
432
|
+
next if ref.include?("@") || ref.include?("#") || ref.include?("{")
|
|
433
|
+
possible = resolve_partial_paths(file, ref)
|
|
434
|
+
unless possible.any? { |p| File.exist?(File.join(Rails.root, "app", "views", p)) }
|
|
435
|
+
warnings << "render \"#{ref}\" \u2014 partial not found"
|
|
436
|
+
end
|
|
233
437
|
end
|
|
438
|
+
warnings
|
|
439
|
+
end
|
|
234
440
|
|
|
235
|
-
|
|
236
|
-
|
|
441
|
+
private_class_method def self.resolve_partial_paths(file, ref)
|
|
442
|
+
paths = []
|
|
443
|
+
if ref.include?("/")
|
|
444
|
+
dir, base = File.dirname(ref), File.basename(ref)
|
|
445
|
+
%w[.html.erb .erb .turbo_stream.erb .json.jbuilder].each { |ext| paths << "#{dir}/_#{base}#{ext}" }
|
|
237
446
|
else
|
|
238
|
-
|
|
447
|
+
view_dir = file.sub(%r{^app/views/}, "").then { |f| File.dirname(f) }
|
|
448
|
+
%w[.html.erb .erb .turbo_stream.erb .json.jbuilder].each { |ext| paths << "#{view_dir}/_#{ref}#{ext}" }
|
|
449
|
+
%w[.html.erb .erb].each { |ext| paths << "shared/_#{ref}#{ext}"; paths << "application/_#{ref}#{ext}" }
|
|
450
|
+
end
|
|
451
|
+
paths
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# ── CHECK 2: Route helpers (AST) ─────────────────────────────────
|
|
455
|
+
|
|
456
|
+
ASSET_HELPER_PREFIXES = %w[image asset font stylesheet javascript audio video file compute_asset auto_discovery_link favicon].freeze
|
|
457
|
+
DEVISE_HELPER_NAMES = %w[session registration password confirmation unlock omniauth_callback user_session user_registration user_password user_confirmation user_unlock].freeze
|
|
458
|
+
|
|
459
|
+
private_class_method def self.check_route_helpers_ast(visitor, context)
|
|
460
|
+
warnings = []
|
|
461
|
+
routes = context[:routes]
|
|
462
|
+
return warnings unless routes && routes[:by_controller]
|
|
463
|
+
valid_names = build_route_name_set(routes)
|
|
464
|
+
return warnings if valid_names.empty?
|
|
465
|
+
|
|
466
|
+
seen = Set.new
|
|
467
|
+
visitor.route_helper_calls.each do |call|
|
|
468
|
+
helper = call[:name]
|
|
469
|
+
next if seen.include?(helper)
|
|
470
|
+
seen << helper
|
|
471
|
+
|
|
472
|
+
name = helper.sub(/_(path|url)\z/, "")
|
|
473
|
+
next if ASSET_HELPER_PREFIXES.any? { |p| name.start_with?(p) }
|
|
474
|
+
next if DEVISE_HELPER_NAMES.include?(name)
|
|
475
|
+
next if %w[edit new polymorphic].include?(name)
|
|
476
|
+
|
|
477
|
+
warnings << "#{helper} \u2014 route helper not found" unless valid_names.include?(name)
|
|
478
|
+
end
|
|
479
|
+
warnings
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Regex fallback
|
|
483
|
+
private_class_method def self.check_route_helpers_regex(content, context)
|
|
484
|
+
warnings = []
|
|
485
|
+
routes = context[:routes]
|
|
486
|
+
return warnings unless routes && routes[:by_controller]
|
|
487
|
+
valid_names = build_route_name_set(routes)
|
|
488
|
+
return warnings if valid_names.empty?
|
|
489
|
+
|
|
490
|
+
seen = Set.new
|
|
491
|
+
content.scan(/\b(\w+)_(path|url)\b/).each do |match|
|
|
492
|
+
name, suffix = match
|
|
493
|
+
helper = "#{name}_#{suffix}"
|
|
494
|
+
next if seen.include?(helper)
|
|
495
|
+
seen << helper
|
|
496
|
+
next if ASSET_HELPER_PREFIXES.any? { |p| name.start_with?(p) }
|
|
497
|
+
next if DEVISE_HELPER_NAMES.include?(name)
|
|
498
|
+
next if %w[edit new polymorphic].include?(name)
|
|
499
|
+
warnings << "#{helper} \u2014 route helper not found" unless valid_names.include?(name)
|
|
500
|
+
end
|
|
501
|
+
warnings
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
private_class_method def self.build_route_name_set(routes)
|
|
505
|
+
names = Set.new
|
|
506
|
+
routes[:by_controller].each_value do |actions|
|
|
507
|
+
actions.each do |a|
|
|
508
|
+
next unless a[:name]
|
|
509
|
+
names << a[:name]
|
|
510
|
+
names << "edit_#{a[:name]}"
|
|
511
|
+
names << "new_#{a[:name]}"
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
names
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# ── CHECK 3: Column references (AST) ─────────────────────────────
|
|
518
|
+
|
|
519
|
+
private_class_method def self.check_column_references_ast(file, visitor, context)
|
|
520
|
+
warnings = []
|
|
521
|
+
return warnings unless file.start_with?("app/models/") && !file.include?("/concerns/")
|
|
522
|
+
|
|
523
|
+
valid = model_valid_columns(file, context)
|
|
524
|
+
return warnings unless valid
|
|
525
|
+
|
|
526
|
+
visitor.validates_calls.each do |vc|
|
|
527
|
+
vc[:columns].each do |col|
|
|
528
|
+
unless valid[:columns].include?(col)
|
|
529
|
+
warnings << "validates :#{col} \u2014 column \"#{col}\" not found in #{valid[:table]} table"
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
warnings
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Regex fallback
|
|
537
|
+
private_class_method def self.check_column_references_regex(file, content, context)
|
|
538
|
+
warnings = []
|
|
539
|
+
return warnings unless file.start_with?("app/models/") && !file.include?("/concerns/")
|
|
540
|
+
|
|
541
|
+
valid = model_valid_columns(file, context)
|
|
542
|
+
return warnings unless valid
|
|
543
|
+
|
|
544
|
+
content.each_line do |line|
|
|
545
|
+
next unless line.match?(/\A\s*validates\s+:/)
|
|
546
|
+
after = line.sub(/\A\s*validates\s+/, "")
|
|
547
|
+
after.scan(/:(\w+)/).each do |m|
|
|
548
|
+
col = m[0]
|
|
549
|
+
break if after.include?("#{col}:")
|
|
550
|
+
next if col == col.capitalize
|
|
551
|
+
warnings << "validates :#{col} \u2014 column \"#{col}\" not found in #{valid[:table]} table" unless valid[:columns].include?(col)
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
warnings
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Shared helper: build valid column set for a model file
|
|
558
|
+
private_class_method def self.model_valid_columns(file, context)
|
|
559
|
+
models = context[:models]
|
|
560
|
+
schema = context[:schema]
|
|
561
|
+
return nil unless models && schema
|
|
562
|
+
|
|
563
|
+
model_name = file.sub("app/models/", "").sub(/\.rb$/, "").camelize
|
|
564
|
+
model_data = models[model_name]
|
|
565
|
+
return nil unless model_data
|
|
566
|
+
|
|
567
|
+
table_name = model_data[:table_name]
|
|
568
|
+
table_data = schema[:tables] && schema[:tables][table_name]
|
|
569
|
+
return nil unless table_data
|
|
570
|
+
|
|
571
|
+
columns = Set.new
|
|
572
|
+
table_data[:columns]&.each { |c| columns << c[:name] }
|
|
573
|
+
model_data[:associations]&.each do |a|
|
|
574
|
+
columns << a[:name] if a[:name]
|
|
575
|
+
columns << a[:foreign_key] if a[:foreign_key]
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
{ columns: columns, table: table_name, model: model_name, model_data: model_data }
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# ── CHECK 4: Strong params vs schema (AST) ───────────────────────
|
|
582
|
+
|
|
583
|
+
private_class_method def self.check_strong_params_ast(file, visitor, context)
|
|
584
|
+
warnings = []
|
|
585
|
+
return warnings unless file.start_with?("app/controllers/")
|
|
586
|
+
return warnings if visitor.permit_calls.empty?
|
|
587
|
+
|
|
588
|
+
schema = context[:schema]
|
|
589
|
+
models = context[:models]
|
|
590
|
+
return warnings unless schema && models
|
|
591
|
+
|
|
592
|
+
# Infer model from controller: posts_controller.rb → Post → posts table
|
|
593
|
+
controller_base = File.basename(file, ".rb").sub(/_controller$/, "")
|
|
594
|
+
model_name = controller_base.classify
|
|
595
|
+
model_data = models[model_name]
|
|
596
|
+
return warnings unless model_data
|
|
597
|
+
|
|
598
|
+
table_name = model_data[:table_name]
|
|
599
|
+
table_data = schema[:tables] && schema[:tables][table_name]
|
|
600
|
+
return warnings unless table_data
|
|
601
|
+
|
|
602
|
+
valid = Set.new
|
|
603
|
+
table_data[:columns]&.each { |c| valid << c[:name] }
|
|
604
|
+
model_data[:associations]&.each { |a| valid << a[:name]; valid << a[:foreign_key] if a[:foreign_key] }
|
|
605
|
+
valid.merge(%w[id _destroy created_at updated_at])
|
|
606
|
+
|
|
607
|
+
# If model has JSONB/JSON columns, params may be stored as hash keys inside them — skip check
|
|
608
|
+
has_json_columns = table_data[:columns]&.any? { |c| %w[jsonb json].include?(c[:type]) }
|
|
609
|
+
return warnings if has_json_columns
|
|
610
|
+
|
|
611
|
+
visitor.permit_calls.each do |pc|
|
|
612
|
+
pc[:params].each do |param|
|
|
613
|
+
next if param.end_with?("_attributes") # nested attributes
|
|
614
|
+
next if valid.include?(param)
|
|
615
|
+
warnings << "permits :#{param} \u2014 not a column in #{table_name} table"
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
warnings
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# ── CHECK 5: Callback method existence (AST) ─────────────────────
|
|
622
|
+
|
|
623
|
+
private_class_method def self.check_callback_existence_ast(file, visitor, context)
|
|
624
|
+
warnings = []
|
|
625
|
+
return warnings unless file.start_with?("app/models/") && !file.include?("/concerns/")
|
|
626
|
+
return warnings if visitor.callback_registrations.empty?
|
|
627
|
+
|
|
628
|
+
models = context[:models]
|
|
629
|
+
return warnings unless models
|
|
630
|
+
|
|
631
|
+
model_name = file.sub("app/models/", "").sub(/\.rb$/, "").camelize
|
|
632
|
+
model_data = models[model_name]
|
|
633
|
+
return warnings unless model_data
|
|
634
|
+
|
|
635
|
+
# Build set of known methods (instance + from source content)
|
|
636
|
+
known = Set.new(model_data[:instance_methods] || [])
|
|
637
|
+
# Also check the file source for private methods
|
|
638
|
+
source = File.read(Rails.root.join(file), encoding: "UTF-8") rescue nil
|
|
639
|
+
source&.scan(/\bdef\s+(\w+[?!]?)/)&.each { |m| known << m[0] }
|
|
640
|
+
|
|
641
|
+
# Skip check if model has concerns (method may be in concern)
|
|
642
|
+
has_concerns = (model_data[:concerns] || []).any?
|
|
643
|
+
|
|
644
|
+
visitor.callback_registrations.each do |reg|
|
|
645
|
+
reg[:methods].each do |method_name|
|
|
646
|
+
next if known.include?(method_name)
|
|
647
|
+
next if has_concerns # uncertain — method may come from concern
|
|
648
|
+
warnings << "#{reg[:type]} :#{method_name} \u2014 method not found in #{model_name}"
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
warnings
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# ── CHECK 6: Route-action consistency (cache only) ───────────────
|
|
655
|
+
|
|
656
|
+
private_class_method def self.check_route_action_consistency(file, context)
|
|
657
|
+
warnings = []
|
|
658
|
+
return warnings unless file.start_with?("app/controllers/")
|
|
659
|
+
|
|
660
|
+
routes = context[:routes]
|
|
661
|
+
controllers = context[:controllers]
|
|
662
|
+
return warnings unless routes && controllers
|
|
663
|
+
|
|
664
|
+
# Map file to controller name: app/controllers/cooks_controller.rb → cooks
|
|
665
|
+
relative = file.sub("app/controllers/", "").sub(/_controller\.rb$/, "")
|
|
666
|
+
ctrl_key = relative.gsub("/", "::")
|
|
667
|
+
ctrl_class = ctrl_key.camelize + "Controller"
|
|
668
|
+
|
|
669
|
+
# Get controller actions
|
|
670
|
+
ctrl_data = controllers[:controllers] && controllers[:controllers][ctrl_class]
|
|
671
|
+
return warnings unless ctrl_data
|
|
672
|
+
actions = Set.new(ctrl_data[:actions] || [])
|
|
673
|
+
|
|
674
|
+
# Get routes pointing to this controller
|
|
675
|
+
route_controller = relative.gsub("::", "/")
|
|
676
|
+
route_actions = routes[:by_controller] && routes[:by_controller][route_controller]
|
|
677
|
+
return warnings unless route_actions
|
|
678
|
+
|
|
679
|
+
route_actions.each do |route|
|
|
680
|
+
action = route[:action]
|
|
681
|
+
next unless action
|
|
682
|
+
unless actions.include?(action)
|
|
683
|
+
warnings << "route #{route[:verb]} #{route[:path]} \u2192 #{action} \u2014 action not found in #{ctrl_class}"
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
warnings
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# ── CHECK 7: has_many without :dependent (cache only) ────────────
|
|
690
|
+
|
|
691
|
+
private_class_method def self.check_has_many_dependent(file, context)
|
|
692
|
+
warnings = []
|
|
693
|
+
return warnings unless file.start_with?("app/models/") && !file.include?("/concerns/")
|
|
694
|
+
|
|
695
|
+
models = context[:models]
|
|
696
|
+
return warnings unless models
|
|
697
|
+
|
|
698
|
+
model_name = file.sub("app/models/", "").sub(/\.rb$/, "").camelize
|
|
699
|
+
model_data = models[model_name]
|
|
700
|
+
return warnings unless model_data
|
|
701
|
+
|
|
702
|
+
(model_data[:associations] || []).each do |assoc|
|
|
703
|
+
next unless assoc[:type] == "has_many"
|
|
704
|
+
next if assoc[:through] # through associations don't need dependent
|
|
705
|
+
next if assoc[:dependent] # already has dependent
|
|
706
|
+
warnings << "has_many :#{assoc[:name]} \u2014 missing :dependent option (orphaned records risk)"
|
|
707
|
+
end
|
|
708
|
+
warnings
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# ── CHECK 8: Missing FK index (cache only) ──────────────────────
|
|
712
|
+
|
|
713
|
+
private_class_method def self.check_missing_fk_index(file, context)
|
|
714
|
+
warnings = []
|
|
715
|
+
return warnings unless file.start_with?("app/models/") && !file.include?("/concerns/")
|
|
716
|
+
|
|
717
|
+
schema = context[:schema]
|
|
718
|
+
models = context[:models]
|
|
719
|
+
return warnings unless schema && models
|
|
720
|
+
|
|
721
|
+
model_name = file.sub("app/models/", "").sub(/\.rb$/, "").camelize
|
|
722
|
+
model_data = models[model_name]
|
|
723
|
+
return warnings unless model_data
|
|
724
|
+
|
|
725
|
+
table_name = model_data[:table_name]
|
|
726
|
+
table_data = schema[:tables] && schema[:tables][table_name]
|
|
727
|
+
return warnings unless table_data
|
|
728
|
+
|
|
729
|
+
# Only flag columns that are ACTUAL foreign keys (declared via add_foreign_key or belongs_to)
|
|
730
|
+
declared_fk_columns = (table_data[:foreign_keys] || []).map { |fk| fk[:column] }
|
|
731
|
+
assoc_fk_columns = (model_data[:associations] || [])
|
|
732
|
+
.select { |a| a[:type] == "belongs_to" }
|
|
733
|
+
.map { |a| a[:foreign_key] }
|
|
734
|
+
.compact
|
|
735
|
+
fk_columns = (declared_fk_columns + assoc_fk_columns).uniq
|
|
736
|
+
|
|
737
|
+
# Build set of indexed columns (first column in any index)
|
|
738
|
+
indexed = Set.new
|
|
739
|
+
(table_data[:indexes] || []).each do |idx|
|
|
740
|
+
indexed << idx[:columns]&.first if idx[:columns]&.any?
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
fk_columns.each do |col|
|
|
744
|
+
unless indexed.include?(col)
|
|
745
|
+
warnings << "#{col} in #{table_name} \u2014 foreign key without index (slow queries)"
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
warnings
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# ── CHECK 9: Stimulus controller existence ───────────────────────
|
|
752
|
+
|
|
753
|
+
private_class_method def self.check_stimulus_controllers(content, context)
|
|
754
|
+
warnings = []
|
|
755
|
+
stimulus = context[:stimulus]
|
|
756
|
+
return warnings unless stimulus
|
|
757
|
+
|
|
758
|
+
# Build known controller names (normalize: both dash and underscore forms)
|
|
759
|
+
known = Set.new
|
|
760
|
+
if stimulus.is_a?(Hash) && stimulus[:controllers]
|
|
761
|
+
stimulus[:controllers].each do |ctrl|
|
|
762
|
+
name = ctrl.is_a?(Hash) ? (ctrl[:name] || ctrl["name"]) : ctrl.to_s
|
|
763
|
+
if name
|
|
764
|
+
known << name
|
|
765
|
+
known << name.tr("_", "-") # underscore → dash
|
|
766
|
+
known << name.tr("-", "_") # dash → underscore
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
elsif stimulus.is_a?(Array)
|
|
770
|
+
stimulus.each do |s|
|
|
771
|
+
name = s.is_a?(Hash) ? (s[:name] || s["name"]) : s.to_s
|
|
772
|
+
if name
|
|
773
|
+
known << name
|
|
774
|
+
known << name.tr("_", "-")
|
|
775
|
+
known << name.tr("-", "_")
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
return warnings if known.empty?
|
|
780
|
+
|
|
781
|
+
# Extract data-controller references from HTML
|
|
782
|
+
content.scan(/data-controller=["']([^"']+)["']/).each do |match|
|
|
783
|
+
controllers = match[0].split(/\s+/)
|
|
784
|
+
controllers.each do |name|
|
|
785
|
+
next if name.include?("<%") || name.include?("#") # dynamic
|
|
786
|
+
next if name.include?("--") # namespaced npm package
|
|
787
|
+
unless known.include?(name)
|
|
788
|
+
warnings << "data-controller=\"#{name}\" \u2014 Stimulus controller not found"
|
|
789
|
+
end
|
|
790
|
+
end
|
|
239
791
|
end
|
|
792
|
+
warnings
|
|
240
793
|
end
|
|
241
794
|
end
|
|
242
795
|
end
|