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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/CONTEXT.md +10 -8
- data/CONTRIBUTING.md +2 -2
- data/README.md +14 -11
- data/SECURITY.md +4 -4
- data/docs/ARCHITECTURE.md +11 -10
- data/docs/CLI.md +33 -7
- data/docs/COMPATIBILITY.md +69 -32
- data/docs/CONFIGURATION.md +10 -8
- data/docs/CUSTOM_TOOLS.md +8 -8
- data/docs/FAQ.md +6 -5
- data/docs/GUIDE.md +154 -80
- data/docs/INTROSPECTORS.md +25 -17
- data/docs/QUICKSTART.md +14 -3
- data/docs/RECIPES.md +50 -40
- data/docs/SECURITY.md +23 -13
- data/docs/SETUP.md +23 -26
- data/docs/STANDALONE.md +7 -3
- data/docs/TOOLS.md +79 -45
- data/docs/TROUBLESHOOTING.md +12 -11
- data/exe/rails-ai-context +8 -4
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/database_yml.rb +52 -2
- data/lib/rails_ai_context/install/ai_tool.rb +11 -2
- data/lib/rails_ai_context/install/cleanup.rb +69 -23
- data/lib/rails_ai_context/install/program.rb +4 -1
- data/lib/rails_ai_context/install/selection_record.rb +20 -2
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +30 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/pg_naming.rb +133 -0
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +0 -6
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +188 -51
- data/lib/rails_ai_context/introspectors/schema_reader.rb +98 -12
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +56 -33
- data/lib/rails_ai_context/payload.rb +25 -2
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/server.rb +2 -2
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
- data/lib/rails_ai_context/test_helper.rb +2 -0
- data/lib/rails_ai_context/tools/base_tool.rb +3 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
- data/lib/rails_ai_context/tools/get_concern.rb +1 -1
- data/lib/rails_ai_context/tools/get_env.rb +1 -1
- data/lib/rails_ai_context/tools/get_gems.rb +2 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +2 -0
- data/lib/rails_ai_context/tools/get_schema.rb +17 -14
- data/lib/rails_ai_context/tools/query.rb +107 -39
- data/lib/rails_ai_context/tools/search_code.rb +20 -16
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
713
|
+
cap = [ limit, HARD_ROW_CAP ].min
|
|
666
714
|
|
|
667
|
-
if sql.match?(
|
|
668
|
-
sql.sub(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
#
|
|
774
|
-
|
|
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
|
|
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")
|
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.32.
|
|
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
|