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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39df765c4841adb33e68870093b8f58d02ff28dfe5e245ee81655902a9b0fb78
4
- data.tar.gz: 58385dad4ad55a627d854a2611ed16f951ac0409d54d80e96966efa8b0e1613b
3
+ metadata.gz: c0d532a0b5cd13d8f15fd988426013b6c493df52bbea861601bc6e98fc50284d
4
+ data.tar.gz: d6937138538b5863998da10507a967a3538354bd80885e2d8be29dce9552331f
5
5
  SHA512:
6
- metadata.gz: 49755cc6de2afc6a536b470abea60b5a873a9f73419754f4ad304dd8988b34ebfa65b7d69e9878e2c7bdd3418183e9a71da16007f374161e87f44259c209f5f7
7
- data.tar.gz: 83441b234d6f429d4a066ec79dece90fd5685d5ee4a3e5df882241b21b2b79cddde40d9c0c4fa3ac79322bf26764cf694a4beaf8dc9c419c82d9bc96e37009e3
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). Replaces separate ruby -c, erb check, and node -c calls. Returns pass/fail for each file with error details."
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
- def self.call(files:, server_context: nil)
30
- if files.empty?
31
- return text_response("No files provided.")
32
- end
39
+ # ── Main entry point ─────────────────────────────────────────────
33
40
 
34
- if files.size > max_files
35
- return text_response("Too many files (#{files.size}). Maximum is #{max_files} per call.")
36
- end
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
- ok, msg = validate_ruby(full_path)
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
- ok, msg = validate_erb(full_path)
76
+ validate_erb(full_path)
71
77
  elsif file.end_with?(".js")
72
- ok, msg = validate_javascript(full_path)
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
- # Validate Ruby syntax via `ruby -c` (no shell — uses Open3 array form)
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
- error = error_lines.any? ? error_lines.join("\n") : "syntax error"
105
- [ false, error ]
152
+ [ false, error_lines.any? ? error_lines.join("\n") : "syntax error", [] ]
106
153
  end
107
154
  end
108
155
 
109
- # Validate ERB by compiling to Ruby source then syntax-checking the result.
110
- # Catches missing <% end %>, unclosed blocks, and mismatched do/end.
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
- check_result, check_status = Open3.capture2e("ruby", "-c", "-", stdin_data: compiled)
135
- if check_status.success?
136
- [ true, nil ]
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
- # Adjust line numbers: subtract 1 for the wrapper def line
139
- error = check_result.lines
140
- .reject { |l| l.strip.empty? || l.include?("Syntax OK") }
141
- .first(5)
142
- .map { |l| l.strip.sub(/-:(\d+):/) { "ruby: -:#{$1.to_i - 1}:" } }
143
- msg = error.any? ? error.join("\n") : "ERB syntax error"
144
- [ false, msg ]
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
- # Validate JavaScript syntax via `node -c` (no shell — uses Open3 array form)
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
- # Show up to 3 non-empty error lines for context
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
- error = error_lines.any? ? error_lines.join("\n") : "syntax error"
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
- in_line_comment = false if char == "\n"
188
- prev_char = char
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
- if in_block_comment
193
- if prev_char == "*" && char == "/"
194
- in_block_comment = false
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
- if in_string
201
- if char == in_string && prev_char != "\\"
202
- in_string = nil
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
- case char
209
- when '"', "'", "`"
210
- in_string = char
211
- when "/"
212
- if prev_char == "/"
213
- in_line_comment = true
214
- stack.pop if stack.last == "/" # remove the first / we may have pushed
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
- when "*"
217
- if prev_char == "/"
218
- in_block_comment = true
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
- else
221
- if openers.key?(char)
222
- stack << char
223
- elsif closers.key?(char)
224
- if stack.empty? || stack.last != closers[char]
225
- line_num = content[0..i].count("\n") + 1
226
- return [ false, "line #{line_num}: unmatched '#{char}'" ]
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
- prev_char = char
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
- if stack.empty?
236
- [ true, nil ]
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
- [ false, "unmatched '#{stack.last}' (node not available, basic check only)" ]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "0.15.7"
4
+ VERSION = "0.15.8"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.7
4
+ version: 0.15.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine