rails-ai-context 5.32.0 → 5.32.2

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 +138 -0
  3. data/CONTEXT.md +10 -8
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +14 -11
  6. data/SECURITY.md +4 -4
  7. data/docs/ARCHITECTURE.md +11 -10
  8. data/docs/CLI.md +33 -7
  9. data/docs/COMPATIBILITY.md +69 -32
  10. data/docs/CONFIGURATION.md +10 -8
  11. data/docs/CUSTOM_TOOLS.md +8 -8
  12. data/docs/FAQ.md +6 -5
  13. data/docs/GUIDE.md +154 -80
  14. data/docs/INTROSPECTORS.md +25 -17
  15. data/docs/QUICKSTART.md +14 -3
  16. data/docs/RECIPES.md +50 -40
  17. data/docs/SECURITY.md +23 -13
  18. data/docs/SETUP.md +23 -26
  19. data/docs/STANDALONE.md +7 -3
  20. data/docs/TOOLS.md +79 -45
  21. data/docs/TROUBLESHOOTING.md +12 -11
  22. data/exe/rails-ai-context +8 -4
  23. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
  24. data/lib/rails_ai_context/configuration.rb +2 -2
  25. data/lib/rails_ai_context/database_yml.rb +52 -2
  26. data/lib/rails_ai_context/install/ai_tool.rb +11 -2
  27. data/lib/rails_ai_context/install/cleanup.rb +69 -23
  28. data/lib/rails_ai_context/install/program.rb +4 -1
  29. data/lib/rails_ai_context/install/selection_record.rb +20 -2
  30. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +30 -9
  31. data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
  32. data/lib/rails_ai_context/introspectors/pg_naming.rb +133 -0
  33. data/lib/rails_ai_context/introspectors/schema_conventions.rb +0 -6
  34. data/lib/rails_ai_context/introspectors/schema_introspector.rb +188 -51
  35. data/lib/rails_ai_context/introspectors/schema_reader.rb +98 -12
  36. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +56 -33
  37. data/lib/rails_ai_context/payload.rb +25 -2
  38. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
  39. data/lib/rails_ai_context/server.rb +2 -2
  40. data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
  41. data/lib/rails_ai_context/test_helper.rb +2 -0
  42. data/lib/rails_ai_context/tools/base_tool.rb +3 -1
  43. data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
  44. data/lib/rails_ai_context/tools/get_concern.rb +1 -1
  45. data/lib/rails_ai_context/tools/get_env.rb +1 -1
  46. data/lib/rails_ai_context/tools/get_gems.rb +2 -2
  47. data/lib/rails_ai_context/tools/get_model_details.rb +2 -0
  48. data/lib/rails_ai_context/tools/get_schema.rb +17 -14
  49. data/lib/rails_ai_context/tools/query.rb +107 -39
  50. data/lib/rails_ai_context/tools/search_code.rb +20 -16
  51. data/lib/rails_ai_context/version.rb +1 -1
  52. data/server.json +1 -1
  53. metadata +2 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "strscan"
4
+
3
5
  module RailsAiContext
4
6
  module Tools
5
7
  class Query < BaseTool
@@ -173,12 +175,17 @@ module RailsAiContext
173
175
  end
174
176
 
175
177
  # ── Layer 1: SQL validation ─────────────────────────────────
176
- valid, error = validate_sql(sql)
178
+ mysql = mysql_dialect?
179
+ valid, error = validate_sql(sql, mysql: mysql)
177
180
  return error_response(error) unless valid
178
181
 
182
+ # Run the text the validator read, never the raw input: whatever the
183
+ # scanner and the database disagree on, nothing unread reaches the database.
184
+ sql = comment_free(sql, mysql: mysql)
185
+
179
186
  # ── EXPLAIN mode ────────────────────────────────────────────
180
187
  if explain
181
- return execute_explain(sql.strip, config.query_timeout)
188
+ return execute_explain(sql, config.query_timeout)
182
189
  end
183
190
 
184
191
  # Resolve row limit
@@ -187,11 +194,11 @@ module RailsAiContext
187
194
  timeout_seconds = config.query_timeout
188
195
 
189
196
  # ── Layers 2-3: Execute with DB-level safety + row limit ────
190
- result = execute_safely(sql.strip, row_limit, timeout_seconds)
197
+ result = execute_safely(sql, row_limit, timeout_seconds)
191
198
 
192
199
  # ── Layer 4: Redact sensitive columns ───────────────────────
193
200
  # Skip for SHOW/DESCRIBE/EXPLAIN - see SCHEMA_METADATA_PREFIX.
194
- redacted = sql.strip.match?(SCHEMA_METADATA_PREFIX) ? result : redact_results(result)
201
+ redacted = sql.match?(SCHEMA_METADATA_PREFIX) ? result : redact_results(result)
195
202
 
196
203
  # ── Format output ───────────────────────────────────────────
197
204
  output = case format
@@ -227,36 +234,73 @@ module RailsAiContext
227
234
  end
228
235
 
229
236
  # ── SQL comment stripping ───────────────────────────────────────
230
- def self.strip_sql_comments(sql)
231
- sql
232
- # MySQL version-conditional comments `/*! ... */` / `/*!12345 ... */`
233
- # are NOT comments - MySQL executes their content. Unwrap them first
234
- # so the validator sees the inner SQL (e.g. `LOAD_FILE`) instead of
235
- # stripping it alongside regular block comments, which would let
236
- # `SELECT /*!50000 LOAD_FILE('/etc/passwd') */` slip past
237
- # BLOCKED_FUNCTIONS. PostgreSQL and SQLite treat `/*! ... */` as
238
- # a normal comment, so unwrapping is MySQL-safe: the content is
239
- # ignored by those engines anyway.
240
- .gsub(/\/\*!\d*\s*(.*?)\*\//m) { " #{Regexp.last_match(1)} " }
241
- .gsub(/\/\*.*?\*\//m, " ") # Block comments: /* ... */
242
- .gsub(/--[^\n]*/, " ") # Line comments: -- ...
243
- .gsub(/^\s*#[^\n]*/m, " ") # MySQL-style comments: # at line start only
244
- .squeeze(" ").strip
237
+ def self.strip_sql_comments(sql, mysql: false)
238
+ comment_free(sql, mysql: mysql).squeeze(" ")
239
+ end
240
+
241
+ QUOTED = /'[^']*'?|"[^"]*"?|`[^`]*`?/m
242
+ # A backslash escapes the next character: every MySQL string, and a PostgreSQL E'...' one.
243
+ ESCAPED_STRING = /'(?:\\.|[^'\\])*'?/m
244
+ MYSQL_QUOTED = /#{ESCAPED_STRING}|"(?:\\.|[^"\\])*"?|`[^`]*`?/m
245
+ DOLLAR_QUOTED = /(?<![\p{Word}$])\$(\p{Word}*)\$.*?\$\1\$/m
246
+ UNQUOTED = /[^'"`$\/\-#*]+/
247
+
248
+ # Comments out, everything else as written: this is the text that runs.
249
+ # A quoted span is copied whole, so a comment marker inside one is data.
250
+ # Only MySQL has a hash comment, a `--` that needs a space after it, and
251
+ # `/*! ... */` content it runs. PostgreSQL has dollar quoting.
252
+ def self.comment_free(sql, mysql: false)
253
+ # fixed_anchor, so the dollar quote's lookbehind sees the text before the pointer.
254
+ scanner = StringScanner.new(sql, fixed_anchor: true)
255
+ line_comment = mysql ? /--(?=\s|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
256
+ out = +""
257
+ executable = false
258
+
259
+ until scanner.eos?
260
+ quoted = if mysql then MYSQL_QUOTED
261
+ elsif out.match?(/(?<![\p{Word}$])[eE]\z/) && scanner.check(/'/) then ESCAPED_STRING
262
+ else QUOTED
263
+ end
264
+
265
+ if (span = scanner.scan(quoted) || (!mysql && scanner.scan(DOLLAR_QUOTED)) || scanner.scan(UNQUOTED))
266
+ out << span
267
+ elsif mysql && scanner.scan(/\/\*!\d*/)
268
+ # Its content stays in the text for the checks to read, e.g. the
269
+ # `LOAD_FILE` in `SELECT /*!50000 LOAD_FILE('/etc/passwd') */`.
270
+ executable = true
271
+ out << " "
272
+ elsif executable && scanner.scan(/\*\//)
273
+ executable = false
274
+ out << " "
275
+ elsif scanner.scan(/\/\*.*?(?:\*\/|\z)/m) || scanner.scan(line_comment)
276
+ out << " "
277
+ else
278
+ out << scanner.getch
279
+ end
280
+ end
281
+
282
+ out.strip
283
+ end
284
+
285
+ private_class_method def self.mysql_dialect?
286
+ ActiveRecord::Base.connection_db_config.adapter.to_s.match?(MYSQL_ADAPTER)
287
+ rescue ActiveRecord::ActiveRecordError
288
+ false
245
289
  end
246
290
 
247
291
  # ── SQL validation (Layer 1) ────────────────────────────────────
248
- def self.validate_sql(sql)
292
+ def self.validate_sql(sql, mysql: false)
249
293
  return [ false, "SQL query is required." ] if sql.nil? || sql.strip.empty?
250
294
 
251
295
  # Belt-and-suspenders: run BLOCKED_FUNCTIONS against the RAW sql before
252
- # stripping comments. If the unwrap logic in strip_sql_comments is ever
296
+ # stripping comments. If the unwrap logic in comment_free is ever
253
297
  # defeated by a novel MySQL comment variant, this still catches the
254
298
  # dangerous primitives (pg_read_file, LOAD_FILE, dblink, ...).
255
299
  if (m = sql.match(BLOCKED_FUNCTIONS))
256
300
  return [ false, "Blocked: dangerous function #{m[0]} (filesystem/network primitive)" ]
257
301
  end
258
302
 
259
- cleaned = strip_sql_comments(sql)
303
+ cleaned = strip_sql_comments(sql, mysql: mysql)
260
304
 
261
305
  # Check multi-statement and clause patterns first - they provide more
262
306
  # specific error messages than the generic keyword blocker.
@@ -332,7 +376,7 @@ module RailsAiContext
332
376
 
333
377
  limited_sql = apply_row_limit(sql, row_limit)
334
378
 
335
- run_guarded(conn, adapter, limited_sql, timeout_seconds)
379
+ cap_rows(run_guarded(conn, adapter, limited_sql, timeout_seconds), sql, row_limit)
336
380
  end
337
381
 
338
382
  # EXPLAIN goes through the adapter wrappers too (READ ONLY, timeout): EXPLAIN ANALYZE runs
@@ -364,7 +408,7 @@ module RailsAiContext
364
408
 
365
409
  private_class_method def self.execute_mysql(conn, sql, timeout)
366
410
  # Inject MAX_EXECUTION_TIME hint for per-query timeout
367
- hinted_sql = if sql.match?(/\ASELECT/i) && !sql.match?(/\/\*\+/)
411
+ hinted_sql = if sql.match?(/\ASELECT/i)
368
412
  sql.sub(/\ASELECT/i, "SELECT /*+ MAX_EXECUTION_TIME(#{(timeout * 1000).to_i}) */")
369
413
  else
370
414
  sql
@@ -499,8 +543,7 @@ module RailsAiContext
499
543
 
500
544
  # ── EXPLAIN execution ────────────────────────────────────────────
501
545
  private_class_method def self.execute_explain(sql, timeout)
502
- cleaned = strip_sql_comments(sql)
503
- unless cleaned.match?(/\A\s*(SELECT|WITH)\b/i)
546
+ unless sql.match?(/\A\s*(SELECT|WITH)\b/i)
504
547
  return text_response("EXPLAIN only supports SELECT queries.")
505
548
  end
506
549
 
@@ -659,26 +702,51 @@ module RailsAiContext
659
702
  end
660
703
 
661
704
  # ── Row limit enforcement (Layer 3) ─────────────────────────────
705
+ # The query's own limit ends the statement. A LIMIT anywhere else belongs
706
+ # to a subquery or a string literal and is left as written.
707
+ TRAILING_LIMIT = /\bLIMIT\s+(?:(\d+)\s*,\s*)?(\d+)(\s+OFFSET\s+\d+)?\s*;?\s*\z/i
708
+ TRAILING_FETCH = /\bFETCH\s+(FIRST|NEXT)\s+(\d+)(\s+ROWS?\s+ONLY)\s*;?\s*\z/i
709
+
662
710
  private_class_method def self.apply_row_limit(sql, limit)
663
711
  return sql if sql.match?(SCHEMA_METADATA_PREFIX)
664
712
 
665
- effective_limit = [ limit, HARD_ROW_CAP ].min
713
+ cap = [ limit, HARD_ROW_CAP ].min
666
714
 
667
- if sql.match?(/\bLIMIT\s+(\d+)/i)
668
- sql.sub(/\bLIMIT\s+(\d+)/i) do
669
- user_limit = $1.to_i
670
- "LIMIT #{[ user_limit, effective_limit ].min}"
671
- end
672
- elsif sql.match?(/\bFETCH\s+FIRST\s+(\d+)/i)
673
- sql.sub(/\bFETCH\s+FIRST\s+(\d+)/i) do
674
- user_limit = $1.to_i
675
- "FETCH FIRST #{[ user_limit, effective_limit ].min}"
676
- end
715
+ if sql.match?(TRAILING_LIMIT)
716
+ sql.sub(TRAILING_LIMIT) { "LIMIT #{"#{$1}, " if $1}#{[ $2.to_i, cap ].min}#{$3}" }
717
+ elsif sql.match?(TRAILING_FETCH)
718
+ sql.sub(TRAILING_FETCH) { "FETCH #{$1} #{[ $2.to_i, cap ].min}#{$3}" }
719
+ elsif own_limit?(sql)
720
+ # A limit in a spelling this does not rewrite (`LIMIT 1+1`, `WITH TIES`).
721
+ # A second one would be a syntax error, so it runs as written and cap_rows holds the cap.
722
+ sql
677
723
  else
678
- "#{sql.chomp.chomp(';')} LIMIT #{effective_limit}"
724
+ # On its own line, so nothing the database reads as a line comment takes it.
725
+ "#{sql.sub(/;\s*\z/, "")}\nLIMIT #{cap}"
679
726
  end
680
727
  end
681
728
 
729
+ # Whether the last LIMIT or FETCH is the statement's own: nothing after
730
+ # it closes a parenthesis or a quote that opened before it.
731
+ private_class_method def self.own_limit?(sql)
732
+ start = sql.rindex(/\b(?:LIMIT|FETCH\s+(?:FIRST|NEXT))\b/i) or return false
733
+ tail = sql[start..]
734
+ depth = 0
735
+ tail.each_char do |char|
736
+ depth += { "(" => 1, ")" => -1 }.fetch(char, 0)
737
+ return false if depth.negative?
738
+ end
739
+ [ "'", '"', "`" ].all? { |quote| tail.count(quote).even? }
740
+ end
741
+
742
+ # The text carries the limit. This holds it whatever the database made of that text.
743
+ private_class_method def self.cap_rows(result, sql, limit)
744
+ cap = [ limit, HARD_ROW_CAP ].min
745
+ return result if sql.match?(SCHEMA_METADATA_PREFIX) || result.rows.size <= cap
746
+
747
+ ResultProxy.new(result.columns, result.rows.first(cap), result.respond_to?(:unbounded) && result.unbounded)
748
+ end
749
+
682
750
  # ── Column redaction (Layer 4) ──────────────────────────────────
683
751
  private_class_method def self.redact_results(result)
684
752
  allowed = allowed_columns
@@ -701,7 +769,7 @@ module RailsAiContext
701
769
  col_down = col.downcase
702
770
  i if encrypted_cols.include?(col_down) || redacted_cols.include?(col_down) ||
703
771
  (!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") })
772
+ (col_down.end_with?(*sensitive_suffixes) || col_down.match?(/password|secret|token/)))
705
773
  }
706
774
 
707
775
  return result if redacted_indices.empty?
@@ -684,8 +684,7 @@ module RailsAiContext
684
684
  # below reads, and `match?` sets none.
685
685
  controller_match = file.match(%r{app/controllers/(.+)_controller\.rb})
686
686
  if category == "Controller" && controller_match
687
- route_actions = extract_controller_actions_from_matches(matches)
688
- routes = find_routes_for_controller(controller_match[1], route_actions, root, ctx)
687
+ routes = find_routes_for_controller(controller_match[1], enclosing_actions(file, matches, root), ctx)
689
688
  route_hint = " → #{routes}" if routes
690
689
  end
691
690
 
@@ -759,24 +758,29 @@ module RailsAiContext
759
758
  end
760
759
 
761
760
  # Extract which action a controller caller is in
762
- private_class_method def self.extract_controller_actions_from_matches(matches)
763
- actions = []
764
- matches.each do |m|
765
- # Match standard RESTful action names from the content
766
- if (match = m[:content].match(/\b(index|show|new|create|edit|update|destroy)\b/))
767
- actions << match[1]
768
- end
769
- end
770
- actions.uniq.first(3)
761
+ # The actions a controller file's matches sit in: the public instance
762
+ # methods of the first class it declares. A call from a macro line, a
763
+ # private method, a class method, a module or a nested class sits in none.
764
+ private_class_method def self.enclosing_actions(file, matches, root)
765
+ source = RailsAiContext::SafeFile.read(File.join(root, file)) || ""
766
+ controller = Introspectors::DeclaredConstant.declared_names(source).first or return []
767
+ actions = Introspectors::ActionResolver.own_methods_in(source, controller).select { |d|
768
+ d[:end_location] && d[:scope] == :instance && d[:visibility] == :public
769
+ }
770
+ matches.filter_map { |m|
771
+ actions.find { |d| (d[:location]..d[:end_location]).cover?(m[:line_number]) }&.dig(:name)
772
+ }.uniq
773
+ rescue => e
774
+ RailsAiContext.debug_fail(e, [], label: "enclosing_actions")
771
775
  end
772
776
 
773
- # Find routes for a controller
774
- private_class_method def self.find_routes_for_controller(ctrl_path, _actions, _root, ctx)
777
+ # Routes of the actions the callers sit in, so the hint never names a
778
+ # route that does not reach the call.
779
+ private_class_method def self.find_routes_for_controller(ctrl_path, actions, ctx)
775
780
  routes = ctx[:routes]
776
781
  return nil unless routes
777
- ctrl_routes = RouteCoverage.all_by_controller(routes)[ctrl_path]
778
- return nil unless ctrl_routes&.any?
779
- # Show the first 2 routes as hints
782
+ ctrl_routes = Array(RouteCoverage.all_by_controller(routes)[ctrl_path]).select { |r| actions.include?(r[:action].to_s) }
783
+ return nil unless ctrl_routes.any?
780
784
  ctrl_routes.first(2).map { |r| "`#{r[:verb]} #{r[:path]}`" }.join(", ")
781
785
  rescue => e
782
786
  RailsAiContext.debug_fail(e, nil, label: "find_routes_for_controller")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.32.0"
4
+ VERSION = "5.32.2"
5
5
  end
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.32.0",
10
+ "version": "5.32.2",
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.32.0
4
+ version: 5.32.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -424,6 +424,7 @@ files:
424
424
  - lib/rails_ai_context/introspectors/node_source.rb
425
425
  - lib/rails_ai_context/introspectors/observability_introspector.rb
426
426
  - lib/rails_ai_context/introspectors/performance_introspector.rb
427
+ - lib/rails_ai_context/introspectors/pg_naming.rb
427
428
  - lib/rails_ai_context/introspectors/pg_partitions.rb
428
429
  - lib/rails_ai_context/introspectors/rake_task_introspector.rb
429
430
  - lib/rails_ai_context/introspectors/recurring_schedules.rb