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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +173 -0
- data/docs/COMPATIBILITY.md +21 -7
- data/docs/CONFIGURATION.md +3 -0
- data/docs/GUIDE.md +7 -1
- data/docs/INTROSPECTORS.md +6 -1
- data/docs/SECURITY.md +23 -1
- data/docs/TOOLS.md +11 -2
- data/lib/rails_ai_context/action_filters.rb +2 -3
- data/lib/rails_ai_context/base_mixins.rb +44 -16
- data/lib/rails_ai_context/concern_macros.rb +698 -92
- data/lib/rails_ai_context/concern_membership.rb +6 -4
- data/lib/rails_ai_context/concern_paths.rb +29 -3
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/introspectors/action_presence.rb +5 -3
- data/lib/rails_ai_context/introspectors/action_resolver.rb +2 -1
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +198 -18
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/declared_constant.rb +15 -6
- data/lib/rails_ai_context/introspectors/job_introspector.rb +55 -10
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -3
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +28 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +3 -1
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +21 -28
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +277 -118
- data/lib/rails_ai_context/introspectors/source_scan.rb +5 -3
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +13 -4
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +104 -21
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +17 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/tools/base_tool.rb +11 -2
- data/lib/rails_ai_context/tools/get_callbacks.rb +14 -6
- data/lib/rails_ai_context/tools/get_concern.rb +1 -1
- data/lib/rails_ai_context/tools/get_conventions.rb +5 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +8 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +4 -3
- data/lib/rails_ai_context/tools/get_schema.rb +4 -0
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/search_code.rb +64 -22
- data/lib/rails_ai_context/tools/search_docs.rb +4 -5
- data/lib/rails_ai_context/tools/security_scan.rb +3 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +1 -1
- data/server.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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}#{
|
|
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 ?
|
|
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
|
-
#
|
|
178
|
-
|
|
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.
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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)}#{
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
data/lib/rails_ai_context.rb
CHANGED
data/server.json
CHANGED
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.
|
|
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
|