rails-ai-context 5.30.2 → 5.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +173 -0
  3. data/docs/COMPATIBILITY.md +21 -7
  4. data/docs/CONFIGURATION.md +3 -0
  5. data/docs/GUIDE.md +7 -1
  6. data/docs/INTROSPECTORS.md +6 -1
  7. data/docs/SECURITY.md +23 -1
  8. data/docs/TOOLS.md +11 -2
  9. data/lib/rails_ai_context/action_filters.rb +2 -3
  10. data/lib/rails_ai_context/base_mixins.rb +44 -16
  11. data/lib/rails_ai_context/concern_macros.rb +698 -92
  12. data/lib/rails_ai_context/concern_membership.rb +6 -4
  13. data/lib/rails_ai_context/concern_paths.rb +29 -3
  14. data/lib/rails_ai_context/configuration.rb +2 -2
  15. data/lib/rails_ai_context/git_ignore.rb +13 -11
  16. data/lib/rails_ai_context/introspectors/action_presence.rb +5 -3
  17. data/lib/rails_ai_context/introspectors/action_resolver.rb +2 -1
  18. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +198 -18
  19. data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -3
  20. data/lib/rails_ai_context/introspectors/declared_constant.rb +15 -6
  21. data/lib/rails_ai_context/introspectors/job_introspector.rb +55 -10
  22. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -3
  23. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +28 -1
  24. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +3 -1
  25. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +21 -28
  26. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  27. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  28. data/lib/rails_ai_context/introspectors/model_introspector.rb +277 -118
  29. data/lib/rails_ai_context/introspectors/source_scan.rb +5 -3
  30. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +13 -4
  31. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +104 -21
  32. data/lib/rails_ai_context/method_name.rb +31 -0
  33. data/lib/rails_ai_context/output_guard.rb +6 -4
  34. data/lib/rails_ai_context/redaction.rb +8 -2
  35. data/lib/rails_ai_context/safe_file.rb +4 -1
  36. data/lib/rails_ai_context/safe_path.rb +17 -4
  37. data/lib/rails_ai_context/server.rb +26 -7
  38. data/lib/rails_ai_context/tools/base_tool.rb +11 -2
  39. data/lib/rails_ai_context/tools/get_callbacks.rb +14 -6
  40. data/lib/rails_ai_context/tools/get_concern.rb +1 -1
  41. data/lib/rails_ai_context/tools/get_conventions.rb +5 -7
  42. data/lib/rails_ai_context/tools/get_job_pattern.rb +8 -2
  43. data/lib/rails_ai_context/tools/get_model_details.rb +4 -3
  44. data/lib/rails_ai_context/tools/get_schema.rb +4 -0
  45. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  46. data/lib/rails_ai_context/tools/query.rb +122 -28
  47. data/lib/rails_ai_context/tools/search_code.rb +64 -22
  48. data/lib/rails_ai_context/tools/search_docs.rb +4 -5
  49. data/lib/rails_ai_context/tools/security_scan.rb +3 -1
  50. data/lib/rails_ai_context/version.rb +1 -1
  51. data/lib/rails_ai_context.rb +1 -1
  52. data/server.json +1 -1
  53. metadata +4 -1
@@ -198,7 +198,7 @@ module RailsAiContext
198
198
  when "csv"
199
199
  format_csv(redacted)
200
200
  else
201
- format_table(redacted)
201
+ format_table(redacted) + unbounded_note(result)
202
202
  end
203
203
 
204
204
  text_response(output)
@@ -312,16 +312,19 @@ module RailsAiContext
312
312
  # Build the combined list ONCE per call and dedupe.
313
313
  configured = Array(config.query_redacted_columns).map { |c| c.to_s.downcase }
314
314
  suffixed = SENSITIVE_COLUMN_SUFFIXES.map(&:downcase)
315
- # An app whose own column merely looks sensitive (oauth_applications.secret)
316
- # exempts it here; the built-in list is frozen and cannot be subtracted from.
317
- allowed = Array(config.query_allowed_columns).map { |c| c.to_s.downcase }
318
- ((configured + suffixed).uniq - allowed).each do |col|
315
+ ((configured + suffixed).uniq - allowed_columns.to_a).each do |col|
319
316
  next if col.empty?
320
317
  return col if down.match?(/\b#{Regexp.escape(col)}\b/)
321
318
  end
322
319
  nil
323
320
  end
324
321
 
322
+ # An app whose own column merely looks sensitive (oauth_applications.secret)
323
+ # exempts it by name, from the pre-query check and from result redaction alike.
324
+ private_class_method def self.allowed_columns
325
+ Array(config.query_allowed_columns).to_set { |c| c.to_s.downcase }
326
+ end
327
+
325
328
  # ── Database-level execution (Layer 2) ──────────────────────────
326
329
  private_class_method def self.execute_safely(sql, row_limit, timeout_seconds)
327
330
  conn = ActiveRecord::Base.connection
@@ -383,29 +386,115 @@ module RailsAiContext
383
386
  result
384
387
  end
385
388
 
389
+ # sqlite3-ruby cannot interrupt a running statement, so a killable child runs it instead.
386
390
  private_class_method def self.execute_sqlite(conn, sql, timeout)
387
- raw = conn.raw_connection
391
+ if (path = sqlite_fork_path(conn))
392
+ begin
393
+ return execute_sqlite_in_child(conn, path, sql, timeout)
394
+ rescue ActiveRecord::StatementInvalid => e
395
+ raise unless e.message.match?(SQLITE_APP_CONNECTION_ONLY)
396
+ end
397
+ end
398
+
388
399
  result = nil
389
400
  begin
390
401
  conn.execute("PRAGMA query_only = ON")
391
- # SQLite has no native statement timeout. Use a progress handler
392
- # to abort queries that run too long (checked every 1000 VM steps).
393
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
394
- if raw.respond_to?(:set_progress_handler)
395
- raw.set_progress_handler(1000) do
396
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
397
- 1 # non-zero = abort
398
- else
399
- 0
400
- end
401
- end
402
- end
403
402
  result = conn.select_all(sql)
404
403
  ensure
405
- raw.set_progress_handler(0, nil) if raw.respond_to?(:set_progress_handler)
406
404
  conn.execute("PRAGMA query_only = OFF")
407
405
  end
408
- result
406
+ ResultProxy.new(result.columns, result.rows, true)
407
+ end
408
+
409
+ # Errors from state only the app's own connection has: registered functions,
410
+ # virtual table modules, collations, an encryption key.
411
+ SQLITE_APP_CONNECTION_ONLY = /no such (?:function|module|collation)|file is not a database/i
412
+
413
+ # A row-capped result is far below this; a larger prefix is a corrupt one.
414
+ SQLITE_MAX_RESULT_BYTES = 1 << 30
415
+
416
+ private_class_method def self.read_before(reader, size, deadline)
417
+ data = "".b
418
+ while data.bytesize < size
419
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
420
+ raise ActiveRecord::StatementInvalid, "SQLite query exceeded the statement timeout" unless left.positive? && IO.select([ reader ], nil, nil, left)
421
+
422
+ chunk = reader.read_nonblock([ size - data.bytesize, 1 << 16 ].min, exception: false)
423
+ raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result" if chunk.nil?
424
+
425
+ data << chunk unless chunk == :wait_readable
426
+ end
427
+ data
428
+ end
429
+
430
+ # nil for an in-memory or temporary database, or where there is no fork.
431
+ private_class_method def self.sqlite_fork_path(conn)
432
+ return nil unless Process.respond_to?(:fork)
433
+
434
+ path = conn.raw_connection.filename("main").to_s
435
+ path unless path.empty?
436
+ end
437
+
438
+ private_class_method def self.execute_sqlite_in_child(conn, path, sql, timeout)
439
+ parent = Process.pid
440
+ extensions = Array(conn.pool.db_config.configuration_hash[:extensions]).map { |ext| (ext.is_a?(String) && ext.safe_constantize) || ext }
441
+ # sqlite3 2.x warns in every child that inherits a writable handle; Rails 8 silences it the same way.
442
+ SQLite3::ForkSafety.suppress_warnings! if defined?(SQLite3::ForkSafety)
443
+
444
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
445
+ reader, writer = IO.pipe.each(&:binmode)
446
+ pid = fork { answer_in_child(reader, writer, path, sql, timeout, extensions) }
447
+ writer.close
448
+ read_child_result(reader, deadline)
449
+ ensure
450
+ # Also reached in a child whose fork hooks raised: it must not carry on as a second server.
451
+ exit!(1) if parent && Process.pid != parent
452
+ reader&.close
453
+ writer&.close
454
+ reap_child(pid) if pid
455
+ end
456
+
457
+ # Writes the length-prefixed result and exits without the parent's at_exit hooks.
458
+ private_class_method def self.answer_in_child(reader, writer, path, sql, timeout, extensions)
459
+ reader.close
460
+ payload = begin
461
+ db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
462
+ db.busy_timeout = (timeout * 1000).to_i
463
+ stmt = db.prepare(sql)
464
+ [ :ok, stmt.columns, stmt.to_a ]
465
+ rescue => e
466
+ [ :error, "#{e.class}: #{e.message}" ]
467
+ end
468
+ bytes = Marshal.dump(payload)
469
+ writer.write([ bytes.bytesize ].pack("Q>"), bytes)
470
+ ensure
471
+ exit!(0)
472
+ end
473
+
474
+ # Read by length, not to EOF: any other process forked meanwhile holds a copy of the writer.
475
+ private_class_method def self.read_child_result(reader, deadline)
476
+ size = read_before(reader, 8, deadline).unpack1("Q>")
477
+ status, *rest = begin
478
+ raise ArgumentError, "result length out of range" if size > SQLITE_MAX_RESULT_BYTES
479
+
480
+ Marshal.load(read_before(reader, size, deadline))
481
+ rescue ArgumentError, TypeError
482
+ raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result"
483
+ end
484
+ raise ActiveRecord::StatementInvalid, rest.first if status == :error
485
+
486
+ ActiveRecord::Result.new(*rest)
487
+ end
488
+
489
+ # KILL: TERM cannot land while `step` holds the GVL, and would run inherited at_exit hooks.
490
+ # Runs in an ensure, so it rescues everything: nothing here may replace the query's own error.
491
+ private_class_method def self.reap_child(pid)
492
+ Process.kill(:KILL, pid) rescue nil
493
+ Process.wait(pid) rescue nil
494
+ end
495
+
496
+ private_class_method def self.unbounded_note(result)
497
+ result.is_a?(ResultProxy) && result.unbounded ? UNBOUNDED_SQLITE_NOTE : ""
409
498
  end
410
499
 
411
500
  # ── EXPLAIN execution ────────────────────────────────────────────
@@ -462,7 +551,7 @@ module RailsAiContext
462
551
  lines << parsed[:raw]
463
552
  lines << "```"
464
553
 
465
- text_response(lines.join("\n"))
554
+ text_response(lines.join("\n") + unbounded_note(result))
466
555
  rescue ActiveRecord::StatementInvalid => e
467
556
  text_response("EXPLAIN failed: #{clean_error_message(e.message)}")
468
557
  end
@@ -592,14 +681,15 @@ module RailsAiContext
592
681
 
593
682
  # ── Column redaction (Layer 4) ──────────────────────────────────
594
683
  private_class_method def self.redact_results(result)
595
- redacted_cols = config.query_redacted_columns.map(&:downcase).to_set
684
+ allowed = allowed_columns
685
+ redacted_cols = config.query_redacted_columns.map(&:downcase).to_set - allowed
686
+ encrypted_cols = Set.new
596
687
 
597
- # Auto-redact columns declared with `encrypts` in models
598
688
  models_data = cached_context&.dig(:models)
599
689
  if models_data.is_a?(Hash)
600
690
  models_data.each_value do |data|
601
691
  next unless data.is_a?(Hash)
602
- (data[:encrypts] || []).each { |col| redacted_cols << col.to_s.downcase }
692
+ (data[:encrypts] || []).each { |col| encrypted_cols << col.to_s.downcase }
603
693
  end
604
694
  end
605
695
  columns = result.columns
@@ -609,8 +699,9 @@ module RailsAiContext
609
699
  sensitive_suffixes = %w[password secret token key digest hash].freeze
610
700
  redacted_indices = columns.each_with_index.filter_map { |col, i|
611
701
  col_down = col.downcase
612
- i if redacted_cols.include?(col_down) ||
613
- sensitive_suffixes.any? { |suffix| col_down.end_with?(suffix) || col_down.include?("password") || col_down.include?("secret") || col_down.include?("token") }
702
+ i if encrypted_cols.include?(col_down) || redacted_cols.include?(col_down) ||
703
+ (!allowed.include?(col_down) &&
704
+ sensitive_suffixes.any? { |suffix| col_down.end_with?(suffix) || col_down.include?("password") || col_down.include?("secret") || col_down.include?("token") })
614
705
  }
615
706
 
616
707
  return result if redacted_indices.empty?
@@ -702,8 +793,11 @@ module RailsAiContext
702
793
  message.lines.first&.strip || message.strip
703
794
  end
704
795
 
705
- # Quacks like ActiveRecord::Result for redacted output.
706
- ResultProxy = Struct.new(:columns, :rows)
796
+ # Quacks like ActiveRecord::Result for redacted output, or for a SQLite
797
+ # query that ran in-process with no time limit.
798
+ ResultProxy = Struct.new(:columns, :rows, :unbounded)
799
+ UNBOUNDED_SQLITE_NOTE = "\n\n_This SQLite query ran without a time limit: query_timeout needs a file-backed database, " \
800
+ "a platform with fork, and nothing that only the app's own connection has._"
707
801
  end
708
802
  end
709
803
  end
@@ -21,6 +21,20 @@ module RailsAiContext
21
21
 
22
22
  TEST_DIRS = %w[test/ spec/ features/].freeze
23
23
  MATCH_FIELD_SEPARATOR = "\x1f"
24
+ # A comment line by the file's own syntax. `#{` and ` * ` are Ruby code;
25
+ # `#id` is a selector in a stylesheet and an element in Haml or Slim.
26
+ HASH_COMMENT = /\A\s*(?:#(?!\{)|<%#)/
27
+ JS_COMMENT_OPEN = %r{\A\s*(?://|/\*)}
28
+ JS_COMMENT = Regexp.union(JS_COMMENT_OPEN, /\A\s*\*\s/)
29
+ CSS_NOT_A_CALL = %r{\A\s*(?:/\*|\*\s|#)}
30
+ STYLESHEET_LINE = Regexp.union(CSS_NOT_A_CALL, %r{\A\s*//})
31
+ NOT_A_CALL_LINE = {
32
+ ".erb" => Regexp.union(HASH_COMMENT, JS_COMMENT_OPEN),
33
+ ".haml" => %r{\A\s*(?:-#|/)},
34
+ ".slim" => %r{\A\s*/},
35
+ ".css" => CSS_NOT_A_CALL
36
+ }.merge(%w[.scss .sass .less].to_h { |ext| [ ext, STYLESHEET_LINE ] })
37
+ .merge(%w[.js .jsx .mjs .cjs .ts .tsx .mts .cts .vue .svelte].to_h { |ext| [ ext, JS_COMMENT ] }).freeze
24
38
 
25
39
  tool_name "rails_search_code"
26
40
  description "Search the Rails codebase with smart modes. " \
@@ -114,14 +128,14 @@ module RailsAiContext
114
128
  cleaned = pattern.sub(/\A\s*def\s+/, "")
115
129
  escaped = literal(cleaned)
116
130
  # `def\s+` already anchors the left edge, so only a trailing boundary.
117
- exact_match ? "^\\s*def\\s+(self\\.)?#{escaped}#{trailing_boundary(cleaned)}" : "^\\s*def\\s+(self\\.)?#{escaped}"
131
+ exact_match ? "^\\s*def\\s+(self\\.)?#{escaped}#{RailsAiContext::MethodName.definition_end(cleaned)}" : "^\\s*def\\s+(self\\.)?#{escaped}"
118
132
  when "class"
119
133
  cleaned = pattern.sub(/\A\s*(class|module)\s+/, "")
120
134
  escaped = literal(cleaned)
121
135
  # `\w*` stays unbounded so a CamelCase prefix still resolves.
122
136
  exact_match ? "^\\s*(class|module)\\s+\\w*#{escaped}#{trailing_boundary(cleaned)}" : "^\\s*(class|module)\\s+\\w*#{escaped}"
123
137
  when "call"
124
- exact_match ? exact_pattern(pattern) : pattern
138
+ exact_match ? method_call_pattern(pattern) : pattern
125
139
  else
126
140
  exact_match ? exact_pattern(pattern) : pattern
127
141
  end
@@ -174,8 +188,10 @@ module RailsAiContext
174
188
  end
175
189
  all_results, truncated = cap_results(fetched)
176
190
 
177
- # Filter out definitions for match_type:"call"
178
- all_results.reject! { |r| r[:content].match?(/\A\s*def\s/) } if match_type == "call"
191
+ # A definition or comment line stays as context, never as a call site.
192
+ if match_type == "call"
193
+ all_results.map! { |r| match_row?(r) && not_a_call_site?(r) ? r.merge(match: false) : r }
194
+ end
179
195
  all_results = confirmed_rows(all_results, root, build_regexp(search_pattern, timeout: 1), context_lines)
180
196
 
181
197
  if all_results.empty?
@@ -249,6 +265,10 @@ module RailsAiContext
249
265
  "#{leading_boundary(pattern)}#{literal(pattern)}#{trailing_boundary(pattern)}"
250
266
  end
251
267
 
268
+ private_class_method def self.method_call_pattern(name)
269
+ "#{leading_boundary(name)}#{literal(name)}#{RailsAiContext::MethodName.call_end(name)}"
270
+ end
271
+
252
272
  # Regexp.escape writes a space as `\ `, which ripgrep 13 rejects as an
253
273
  # unknown escape; a bare space means the same to both engines.
254
274
  private_class_method def self.literal(text)
@@ -310,11 +330,6 @@ module RailsAiContext
310
330
  cmd << "--glob=!#{p}"
311
331
  end
312
332
 
313
- # Block sensitive files from search results
314
- RailsAiContext.configuration.sensitive_patterns.each do |p|
315
- cmd << "--glob=!#{p}"
316
- end
317
-
318
333
  ai_context_paths.each do |p|
319
334
  cmd << "--glob=!#{p}"
320
335
  end
@@ -336,18 +351,19 @@ module RailsAiContext
336
351
  cmd << pattern
337
352
  cmd << search_path
338
353
 
339
- output, status = Open3.capture2(*cmd, err: File::NULL)
354
+ output, err, status = Open3.capture3(*cmd)
340
355
 
341
356
  # rg exits 1 for "no matches" and 2 for any error, including one it
342
357
  # recovered from - an unreadable file in the tree - and it still
343
358
  # prints every match it found. An rg too old for a flag prints
344
359
  # nothing, so an empty result from a failed run is the one worth
345
360
  # rerunning; a full one is kept and the answer says an error was hit.
346
- failed = !(status.success? || status.exitstatus == 1)
361
+ failed = !(status.success? || status.exitstatus == 1) && rg_error_outside_sensitive?(err, root)
347
362
  if failed && output.empty?
348
363
  return search_with_ruby(pattern, search_path, file_type, max_results, root, ctx_lines, exclude_tests: exclude_tests)
349
364
  end
350
365
 
366
+ # SafePath, not an rg glob, drops sensitive files: a glob cannot exempt a placeholder.
351
367
  rows = parse_rg_output(output, root)
352
368
  .reject { |r| sensitive_file?(r[:file]) }
353
369
  .first(max_results)
@@ -356,6 +372,18 @@ module RailsAiContext
356
372
  [ [ { file: "error", line_number: 0, content: e.message } ], false ]
357
373
  end
358
374
 
375
+ # rg opens sensitive files too, and one it cannot read is not an error
376
+ # the answer reports. A line that names no path counts as an error.
377
+ private_class_method def self.rg_error_outside_sensitive?(err, root)
378
+ lines = err.to_s.scrub.lines.map(&:chomp).reject(&:empty?)
379
+ return true if lines.empty?
380
+
381
+ lines.any? do |line|
382
+ path = line.delete_prefix("rg: ")[/\A(.+?): [^:]*\(os error \d+\)\z/, 1]
383
+ path.nil? || !sensitive_file?(path.delete_prefix("#{root}/"))
384
+ end
385
+ end
386
+
359
387
  private_class_method def self.search_with_ruby(pattern, search_path, file_type, max_results, root, ctx_lines = 0, exclude_tests: false)
360
388
  results = []
361
389
  search_error = false
@@ -452,14 +480,17 @@ module RailsAiContext
452
480
  # runs on the raw file, so a hit there would confirm what the secret starts with. Context rows
453
481
  # stay only beside a real hit, so a right guess shows what a wrong one does.
454
482
  private_class_method def self.confirmed_rows(rows, root, regex, ctx_lines)
455
- kept = rows.zip(redact_rows(rows, root)).map do |raw, row|
456
- hidden = match_row?(row) && raw[:content].to_s.chomp.match?(regex) && !row[:content].match?(regex)
457
- hidden ? row.merge(match: false) : row
483
+ redacted_rows = redact_rows(rows, root)
484
+ kept = begin
485
+ rows.zip(redacted_rows).map do |raw, row|
486
+ hidden = match_row?(row) && raw[:content].to_s.chomp.match?(regex) && !row[:content].match?(regex)
487
+ hidden ? row.merge(match: false) : row
488
+ end
489
+ rescue Regexp::TimeoutError
490
+ redacted_rows
458
491
  end
459
492
  hits = kept.select { |r| match_row?(r) }.group_by { |r| r[:file] }
460
493
  kept.select { |r| match_row?(r) || hits.fetch(r[:file], []).any? { |h| (h[:line_number] - r[:line_number]).abs <= ctx_lines } }
461
- rescue Regexp::TimeoutError
462
- redact_rows(rows, root)
463
494
  end
464
495
 
465
496
  private_class_method def self.redacted(row)
@@ -470,6 +501,18 @@ module RailsAiContext
470
501
  row[:match] != false
471
502
  end
472
503
 
504
+ # A definition or a comment line names a method without calling it.
505
+ private_class_method def self.not_a_call_site?(row)
506
+ content = row[:content].to_s
507
+ return true if content.match?(/\A\s*def\s/)
508
+
509
+ ext = File.extname(row[:file].to_s)
510
+ # A live ERB tag runs whatever comment surrounds it.
511
+ return false if ext == ".erb" && content.match?(/<%(?!#)/)
512
+
513
+ content.match?(NOT_A_CALL_LINE.fetch(ext, HASH_COMMENT))
514
+ end
515
+
473
516
  private_class_method def self.match_count(rows)
474
517
  rows.count { |r| match_row?(r) }
475
518
  end
@@ -523,7 +566,7 @@ module RailsAiContext
523
566
  lines = [ "# Trace: `#{cleaned}`", "" ]
524
567
 
525
568
  # 1. Find the definition
526
- def_pattern = "^\\s*def\\s+(self\\.)?#{literal(cleaned)}#{trailing_boundary(cleaned)}"
569
+ def_pattern = "^\\s*def\\s+(self\\.)?#{literal(cleaned)}#{RailsAiContext::MethodName.definition_end(cleaned)}"
527
570
  def_results, = quick_search(def_pattern, search_path, root, 10, exclude_tests)
528
571
 
529
572
  if def_results.any?
@@ -569,11 +612,10 @@ module RailsAiContext
569
612
  end
570
613
 
571
614
  # 2. Find all callers (everywhere the method is referenced, excluding the def line)
572
- call_pattern = exact_pattern(cleaned)
615
+ call_pattern = method_call_pattern(cleaned)
573
616
  call_rows, = quick_search(call_pattern, search_path, root, max_results_cap + 1, exclude_tests)
574
617
  call_results, call_truncated = cap_results(call_rows)
575
- # A `#` line mentioning the method is prose about it, not a call site.
576
- callers = call_results.reject { |r| r[:content].match?(/\A\s*(def\s|#)/) }
618
+ callers = call_results.reject { |r| not_a_call_site?(r) }
577
619
 
578
620
  # Exclude the definition file+line to avoid self-reference
579
621
  def_locations = def_results.map { |r| "#{r[:file]}:#{r[:line_number]}" }.to_set
@@ -679,9 +721,9 @@ module RailsAiContext
679
721
  source.each_line do |line|
680
722
  in_private = true if line.match?(/\A\s*private\s*$/)
681
723
  next if in_private
682
- if (m = line.match(/\A\s*def\s+((?:self\.)?\w+[?!]?)/))
724
+ if (m = line.match(/\A\s*def\s+((?:self\.)?\w+[?!=]?)/))
683
725
  name = m[1]
684
- methods << name unless name == exclude_method || name.start_with?("initialize")
726
+ methods << name unless name.delete_prefix("self.") == exclude_method || name.start_with?("initialize")
685
727
  end
686
728
  end
687
729
  methods
@@ -197,8 +197,8 @@ module RailsAiContext
197
197
  cache_key = "#{topic['id']}_#{branch}.md"
198
198
  cache_file = cache_dir.join(cache_key)
199
199
 
200
- # Use cached file if < 24 hours old
201
- if File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < 86_400
200
+ # Use cached file if < 24 hours old; an empty one is a failed write, not a doc.
201
+ if File.size?(cache_file) && (Time.now - File.mtime(cache_file)) < 86_400
202
202
  return RailsAiContext::SafeFile.read(cache_file)
203
203
  end
204
204
 
@@ -214,9 +214,8 @@ module RailsAiContext
214
214
 
215
215
  max_fetch_bytes = 2_000_000 # 2MB safety cap
216
216
  if response.is_a?(Net::HTTPSuccess)
217
- body = response.body
218
- body = body.byteslice(0, max_fetch_bytes) if body.bytesize > max_fetch_bytes
219
- File.write(cache_file, body)
217
+ body = response.body.byteslice(0, max_fetch_bytes).force_encoding(Encoding::UTF_8).scrub("?")
218
+ RailsAiContext::SafeFile.atomic_write(cache_file, body)
220
219
  body
221
220
  else
222
221
  "#{topic['summary']}\n→ #{url}\n_(fetch failed: HTTP #{response.code})_"
@@ -117,7 +117,9 @@ module RailsAiContext
117
117
  quiet: true,
118
118
  report_progress: false,
119
119
  min_confidence: min_confidence,
120
- print_report: false
120
+ print_report: false,
121
+ # Forked parse workers cannot Marshal the Binding web-console's bindex hangs on a parse error.
122
+ parallel_checks: false
121
123
  }
122
124
  options[:run_checks] = Set.new(resolved_checks) if resolved_checks
123
125
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.30.2"
4
+ VERSION = "5.31.0"
5
5
  end
@@ -44,7 +44,7 @@ module RailsAiContext
44
44
  # Global configuration
45
45
  attr_writer :configuration
46
46
 
47
- # True while the stdio transport owns $stdout for JSON-RPC.
47
+ # True while a stdio session is serving JSON-RPC.
48
48
  attr_accessor :stdio_open
49
49
 
50
50
  def configuration
data/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "5.30.2",
10
+ "version": "5.31.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
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: 5.30.2
4
+ version: 5.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -378,7 +378,9 @@ files:
378
378
  - lib/rails_ai_context/introspectors/listeners/mixins_listener.rb
379
379
  - lib/rails_ai_context/introspectors/listeners/model_reference_listener.rb
380
380
  - lib/rails_ai_context/introspectors/listeners/mount_listener.rb
381
+ - lib/rails_ai_context/introspectors/listeners/owner_scope.rb
381
382
  - lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb
383
+ - lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb
382
384
  - lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb
383
385
  - lib/rails_ai_context/introspectors/listeners/route_files_listener.rb
384
386
  - lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb
@@ -425,6 +427,7 @@ files:
425
427
  - lib/rails_ai_context/live_reload.rb
426
428
  - lib/rails_ai_context/mcp_config_generator.rb
427
429
  - lib/rails_ai_context/mcp_edge.rb
430
+ - lib/rails_ai_context/method_name.rb
428
431
  - lib/rails_ai_context/middleware.rb
429
432
  - lib/rails_ai_context/migration_status.rb
430
433
  - lib/rails_ai_context/option_text.rb