rails_agent_console 0.1.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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +414 -0
  5. data/exe/rails-agent +7 -0
  6. data/lib/generators/rails_agent_console/install_generator.rb +17 -0
  7. data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
  8. data/lib/rails_agent_console/agent.rb +277 -0
  9. data/lib/rails_agent_console/cli.rb +114 -0
  10. data/lib/rails_agent_console/configuration.rb +170 -0
  11. data/lib/rails_agent_console/console_installer.rb +49 -0
  12. data/lib/rails_agent_console/console_methods.rb +54 -0
  13. data/lib/rails_agent_console/console_session.rb +41 -0
  14. data/lib/rails_agent_console/credentials.rb +66 -0
  15. data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
  16. data/lib/rails_agent_console/diagnosis.rb +149 -0
  17. data/lib/rails_agent_console/errors.rb +20 -0
  18. data/lib/rails_agent_console/executor.rb +190 -0
  19. data/lib/rails_agent_console/history.rb +65 -0
  20. data/lib/rails_agent_console/model_picker.rb +166 -0
  21. data/lib/rails_agent_console/prompt/dates.rb +34 -0
  22. data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
  23. data/lib/rails_agent_console/prompt.rb +122 -0
  24. data/lib/rails_agent_console/proposal.rb +208 -0
  25. data/lib/rails_agent_console/providers/anthropic.rb +48 -0
  26. data/lib/rails_agent_console/providers/base.rb +114 -0
  27. data/lib/rails_agent_console/providers/callable.rb +28 -0
  28. data/lib/rails_agent_console/providers/gemini.rb +45 -0
  29. data/lib/rails_agent_console/providers/ollama.rb +84 -0
  30. data/lib/rails_agent_console/providers/openai.rb +51 -0
  31. data/lib/rails_agent_console/providers.rb +28 -0
  32. data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
  33. data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
  34. data/lib/rails_agent_console/query_validator/result.rb +55 -0
  35. data/lib/rails_agent_console/query_validator/rules.rb +108 -0
  36. data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
  37. data/lib/rails_agent_console/query_validator.rb +305 -0
  38. data/lib/rails_agent_console/railtie.rb +26 -0
  39. data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
  40. data/lib/rails_agent_console/rewriter/chain.rb +126 -0
  41. data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
  42. data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
  43. data/lib/rails_agent_console/rewriter/joined.rb +42 -0
  44. data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
  45. data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
  46. data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
  47. data/lib/rails_agent_console/rewriter.rb +183 -0
  48. data/lib/rails_agent_console/schema.rb +275 -0
  49. data/lib/rails_agent_console/setup.rb +40 -0
  50. data/lib/rails_agent_console/ui.rb +125 -0
  51. data/lib/rails_agent_console/value_check.rb +85 -0
  52. data/lib/rails_agent_console/version.rb +5 -0
  53. data/lib/rails_agent_console/wizard.rb +235 -0
  54. data/lib/rails_agent_console.rb +58 -0
  55. metadata +145 -0
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module RailsAgentConsole
8
+ module Providers
9
+ class Base
10
+ attr_reader :config
11
+
12
+ def initialize(config = RailsAgentConsole.configuration)
13
+ @config = config
14
+ end
15
+
16
+ def chat(system:, messages:)
17
+ response = post(endpoint, headers, payload(system: system, messages: messages))
18
+ extract(response)
19
+ end
20
+
21
+ def name
22
+ self.class.name.split("::").last
23
+ end
24
+
25
+ def model
26
+ config.model
27
+ end
28
+
29
+ def models
30
+ []
31
+ end
32
+
33
+ def default_base
34
+ self.class::DEFAULT_BASE
35
+ end
36
+
37
+ private
38
+
39
+ def get(uri, headers = {})
40
+ uri = URI(uri)
41
+ http = Net::HTTP.new(uri.host, uri.port)
42
+ http.use_ssl = uri.scheme == "https"
43
+ http.open_timeout = 5
44
+ http.read_timeout = 10
45
+ response = http.request(Net::HTTP::Get.new(uri.request_uri, headers))
46
+ JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ def endpoint
52
+ raise NotImplementedError
53
+ end
54
+
55
+ def headers
56
+ { "Content-Type" => "application/json" }
57
+ end
58
+
59
+ def payload(system:, messages:)
60
+ raise NotImplementedError
61
+ end
62
+
63
+ def extract(_response)
64
+ raise NotImplementedError
65
+ end
66
+
67
+ def api_key!
68
+ config.api_key or raise ConfigurationError, "Missing API key for #{config.provider}"
69
+ end
70
+
71
+ def post(uri, headers, body)
72
+ uri = URI(uri)
73
+ http = Net::HTTP.new(uri.host, uri.port)
74
+ http.use_ssl = uri.scheme == "https"
75
+ http.open_timeout = 10
76
+ http.read_timeout = config.request_timeout
77
+
78
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
79
+ request.body = JSON.generate(body)
80
+
81
+ response = http.request(request)
82
+ handle(response)
83
+ rescue Net::OpenTimeout, Net::ReadTimeout
84
+ raise ProviderError, "#{name} did not respond within #{config.request_timeout}s"
85
+ rescue SocketError, Errno::ECONNREFUSED => e
86
+ raise ProviderError, "Could not reach #{name}: #{e.message}"
87
+ end
88
+
89
+ def handle(response)
90
+ body = response.body.to_s
91
+ return JSON.parse(body) if response.is_a?(Net::HTTPSuccess)
92
+
93
+ raise ProviderError, "#{name} returned #{response.code}: #{error_message(body)}"
94
+ rescue JSON::ParserError
95
+ raise ProviderError, "#{name} returned a non-JSON response: #{body[0, 200]}"
96
+ end
97
+
98
+ def error_message(body)
99
+ parsed = JSON.parse(body)
100
+ return body[0, 200] unless parsed.is_a?(Hash)
101
+
102
+ error = parsed["error"]
103
+ message = error.is_a?(Hash) ? error["message"] : error
104
+ (message || parsed["message"] || body[0, 200]).to_s[0, 200]
105
+ rescue JSON::ParserError
106
+ body[0, 200]
107
+ end
108
+
109
+ def base_url(default)
110
+ config.api_base || default
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Providers
5
+ # Example:
6
+ # RailsAgentConsole.configure do |c|
7
+ # c.client = ->(system:, messages:) do
8
+ # RubyLLM.chat.with_instructions(system).ask(messages.last[:content]).content
9
+ # end
10
+ # end
11
+ class Callable < Base
12
+ def name
13
+ "custom client"
14
+ end
15
+
16
+ def model
17
+ config.model
18
+ end
19
+
20
+ def chat(system:, messages:)
21
+ result = config.client.call(system: system, messages: messages)
22
+ raise InvalidResponseError, "config.client returned nothing" if result.to_s.strip.empty?
23
+
24
+ result.to_s
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Providers
5
+ class Gemini < Base
6
+ DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta"
7
+
8
+ def name
9
+ "Gemini"
10
+ end
11
+
12
+ def models
13
+ listed = Array(get("#{base_url(DEFAULT_BASE)}/models?pageSize=200&key=#{api_key!}")&.dig("models"))
14
+ listed.select { |entry| Array(entry["supportedGenerationMethods"]).include?("generateContent") }
15
+ .filter_map { |entry| entry["name"].to_s.delete_prefix("models/") }
16
+ rescue ConfigurationError
17
+ []
18
+ end
19
+
20
+ private
21
+
22
+ def endpoint
23
+ "#{base_url(DEFAULT_BASE)}/models/#{model}:generateContent?key=#{api_key!}"
24
+ end
25
+
26
+ def payload(system:, messages:)
27
+ {
28
+ system_instruction: { parts: [{ text: system }] },
29
+ generationConfig: { temperature: config.temperature },
30
+ contents: messages.map do |message|
31
+ { role: message[:role] == "assistant" ? "model" : "user", parts: [{ text: message[:content] }] }
32
+ end
33
+ }
34
+ end
35
+
36
+ def extract(response)
37
+ parts = response.dig("candidates", 0, "content", "parts")
38
+ content = Array(parts).filter_map { |part| part["text"] }.join.strip
39
+ raise InvalidResponseError, "Gemini returned no content" if content.empty?
40
+
41
+ content
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Providers
5
+ class Ollama < Base
6
+ DEFAULT_BASE = "http://localhost:11434"
7
+ CONTEXT_TOKENS = 8192
8
+ ANSWER_TOKENS = 1024
9
+ MISSING_MODEL = /model ['"]?([^'"]+?)['"]? not found/
10
+ RECOMMENDED = "qwen2.5-coder:7b"
11
+
12
+ def name
13
+ "Ollama"
14
+ end
15
+
16
+ def chat(system:, messages:)
17
+ super
18
+ rescue ProviderError => e
19
+ missing = e.message[MISSING_MODEL, 1]
20
+ raise if missing.nil?
21
+
22
+ switch_from(missing)
23
+ super
24
+ end
25
+
26
+ def installed_models
27
+ Array(get("#{base_url(DEFAULT_BASE)}/api/tags")&.dig("models")).filter_map { |model| model["name"] }
28
+ end
29
+
30
+ def models
31
+ installed_models
32
+ end
33
+
34
+ private
35
+
36
+ def switch_from(missing)
37
+ installed = installed_models
38
+ raise ProviderError, nothing_installed(missing) if installed.empty?
39
+
40
+ config.model = pick(installed)
41
+ UI.new(config).warn("#{missing} is not installed in Ollama, so this uses #{config.model}. " \
42
+ "Keep it with ai_model \"ollama/#{config.model}\", #{way_to(missing)}.")
43
+ end
44
+
45
+ def way_to(missing)
46
+ owner = Configuration.provider_for(missing)
47
+ return "or install the other with `ollama pull #{missing}`" if owner.nil?
48
+
49
+ "or use #{missing} where it runs with ai_model \"#{owner}/#{missing}\""
50
+ end
51
+
52
+ def pick(installed)
53
+ installed.find { |model| model.start_with?(RECOMMENDED) } ||
54
+ installed.find { |model| model.include?("coder") } ||
55
+ installed.first
56
+ end
57
+
58
+ def nothing_installed(missing)
59
+ "Ollama has no models installed, so #{missing} cannot answer. " \
60
+ "Install one with `ollama pull #{RECOMMENDED}` and ask again."
61
+ end
62
+
63
+ def endpoint
64
+ "#{base_url(DEFAULT_BASE)}/api/chat"
65
+ end
66
+
67
+ def payload(system:, messages:)
68
+ {
69
+ model: model,
70
+ stream: false,
71
+ options: { temperature: config.temperature, num_ctx: CONTEXT_TOKENS, num_predict: ANSWER_TOKENS },
72
+ messages: [{ role: "system", content: system }] + messages
73
+ }
74
+ end
75
+
76
+ def extract(response)
77
+ content = response.dig("message", "content").to_s
78
+ raise InvalidResponseError, "Ollama returned no content" if content.strip.empty?
79
+
80
+ content
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Providers
5
+ class OpenAI < Base
6
+ DEFAULT_BASE = "https://api.openai.com/v1"
7
+
8
+ CHAT_MODEL = /\A(gpt-|chatgpt-|o\d)/
9
+ NOT_CHAT = /audio|realtime|tts|transcribe|image|search|embedding|instruct|-\d{4}(-\d\d-\d\d)?\z/
10
+
11
+ def name
12
+ "OpenAI"
13
+ end
14
+
15
+ def models
16
+ listed = Array(get("#{base_url(DEFAULT_BASE)}/models", headers)&.dig("data"))
17
+ listed = listed.sort_by { |entry| -entry["created"].to_i }.filter_map { |entry| entry["id"] }
18
+ return listed unless base_url(DEFAULT_BASE) == DEFAULT_BASE
19
+
20
+ listed.grep(CHAT_MODEL).grep_v(NOT_CHAT)
21
+ rescue ConfigurationError
22
+ []
23
+ end
24
+
25
+ private
26
+
27
+ def endpoint
28
+ "#{base_url(DEFAULT_BASE)}/chat/completions"
29
+ end
30
+
31
+ def headers
32
+ super.merge("Authorization" => "Bearer #{api_key!}")
33
+ end
34
+
35
+ def payload(system:, messages:)
36
+ {
37
+ model: model,
38
+ temperature: config.temperature,
39
+ messages: [{ role: "system", content: system }] + messages
40
+ }
41
+ end
42
+
43
+ def extract(response)
44
+ content = response.dig("choices", 0, "message", "content")
45
+ raise InvalidResponseError, "OpenAI returned no content" if content.nil? || content.strip.empty?
46
+
47
+ content
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "providers/base"
4
+ require_relative "providers/openai"
5
+ require_relative "providers/anthropic"
6
+ require_relative "providers/gemini"
7
+ require_relative "providers/ollama"
8
+ require_relative "providers/callable"
9
+
10
+ module RailsAgentConsole
11
+ module Providers
12
+ REGISTRY = {
13
+ openai: OpenAI,
14
+ anthropic: Anthropic,
15
+ gemini: Gemini,
16
+ ollama: Ollama
17
+ }.freeze
18
+
19
+ def self.build(config = RailsAgentConsole.configuration)
20
+ return Callable.new(config) if config.client
21
+
22
+ klass = REGISTRY[config.provider]
23
+ raise ConfigurationError, "Unknown provider #{config.provider.inspect}" unless klass
24
+
25
+ klass.new(config)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ class QueryValidator
5
+ module ModelSuggestion
6
+ module_function
7
+
8
+ def for(name)
9
+ close = Schema.model_names.select { |model| close?(model.split("::").last.downcase, name.downcase) }
10
+ close.empty? ? "" : " (did you mean #{close.first(3).join(" or ")}?)"
11
+ rescue StandardError
12
+ ""
13
+ end
14
+
15
+ def close?(model, wanted)
16
+ single = wanted.singularize
17
+ model.start_with?(single) || model.end_with?(single) || wanted.start_with?(model) ||
18
+ edit_distance(model, wanted) <= 2
19
+ end
20
+
21
+ def edit_distance(left, right)
22
+ previous = (0..right.length).to_a
23
+ left.each_char.with_index(1) do |left_char, row|
24
+ current = [row]
25
+ right.each_char.with_index(1) do |right_char, column|
26
+ cost = left_char == right_char ? 0 : 1
27
+ current << [previous[column] + 1, current[column - 1] + 1, previous[column - 1] + cost].min
28
+ end
29
+ previous = current
30
+ end
31
+ previous.last
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module RailsAgentConsole
6
+ class QueryValidator
7
+ class ParseError < Ripper
8
+ def self.message_for(code)
9
+ parser = new(code)
10
+ parser.parse
11
+ parser.message
12
+ rescue StandardError
13
+ nil
14
+ end
15
+
16
+ attr_reader :message
17
+
18
+ def on_parse_error(message)
19
+ @message = message if @message.nil?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ class QueryValidator
5
+ class Result
6
+ attr_reader :code, :violations, :writes, :methods, :constants, :unknown, :unknown_constants
7
+
8
+ def initialize(code:, violations:, writes:, methods:, constants:,
9
+ unknown: [], unknown_constants: [], blocked: false, syntax_error: false)
10
+ @code = code
11
+ @violations = violations.uniq
12
+ @writes = writes.uniq
13
+ @methods = methods.uniq
14
+ @constants = constants.uniq
15
+ @unknown = unknown.uniq
16
+ @unknown_constants = unknown_constants.uniq
17
+ @blocked = blocked
18
+ @syntax_error = syntax_error
19
+ end
20
+
21
+ def valid?
22
+ @violations.empty?
23
+ end
24
+
25
+ def blocked?
26
+ @blocked
27
+ end
28
+
29
+ def syntax_error?
30
+ @syntax_error
31
+ end
32
+
33
+ def repairable?
34
+ !valid? && !blocked? && @writes.empty? &&
35
+ (syntax_error? || @unknown.any? || @unknown_constants.any?)
36
+ end
37
+
38
+ def writes?
39
+ @writes.any?
40
+ end
41
+
42
+ def destructive?
43
+ @writes.any? { |name| Rules::DESTRUCTIVE.include?(name) }
44
+ end
45
+
46
+ def models
47
+ @constants & Schema.model_names
48
+ end
49
+
50
+ def to_s
51
+ valid? ? "valid" : "invalid: #{@violations.join("; ")}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module RailsAgentConsole
6
+ class QueryValidator
7
+ module Rules
8
+ FORBIDDEN = %w[
9
+ eval instance_eval class_eval module_eval instance_exec class_exec binding
10
+ send __send__ public_send method define_method method_missing
11
+ system exec spawn fork syscall exit exit! abort at_exit trap sleep
12
+ require require_relative load autoload open popen
13
+ const_get const_set remove_const instance_variable_get instance_variable_set
14
+ class_variable_get class_variable_set attr_accessor alias_method undef_method
15
+ connection connection_pool with_connection raw_connection execute exec_query
16
+ exec_update exec_delete select_all select_one select_value find_by_sql
17
+ count_by_sql sanitize_sql create_table drop_table change_table add_column
18
+ remove_column rename_table truncate truncate_tables migrate rollback
19
+ establish_connection remove_connection instance_variables methods
20
+ caller caller_locations gets `
21
+ ].to_set.freeze
22
+
23
+ WRITE = %w[
24
+ save save! create create! create_or_find_by create_or_find_by!
25
+ find_or_create_by find_or_create_by! first_or_create first_or_create!
26
+ first_or_initialize new build update update! update_attribute
27
+ update_attributes update_column update_columns update_all upsert upsert_all
28
+ insert insert! insert_all insert_all! import touch touch_all
29
+ increment! decrement! toggle! reset_counters
30
+ delete delete_all delete_by destroy destroy! destroy_all destroy_by
31
+ transaction lock lock! with_lock
32
+ ].to_set.freeze
33
+
34
+ DESTRUCTIVE = %w[
35
+ delete delete_all delete_by destroy destroy! destroy_all destroy_by
36
+ update_all update_column update_columns
37
+ ].to_set.freeze
38
+
39
+ ALLOWED = %w[
40
+ all where not or and rewhere unscope only except merge none from
41
+ joins left_joins left_outer_joins includes preload eager_load references
42
+ group having order reorder reverse_order in_order_of
43
+ limit offset distinct select pluck pick ids extract_associated
44
+ readonly strict_loading annotate optimizer_hints with
45
+ find find_by find_by! first first! last last! take take! sole find_sole_by
46
+ find_each find_in_batches in_batches each each_with_index each_with_object
47
+ exists? any? many? empty? none? one? present? blank? nil? include?
48
+ count sum average minimum maximum size length calculate tally
49
+ group_by index_by index_with partition
50
+ map collect flat_map filter_map filter reject sort sort_by
51
+ min min_by max max_by reduce inject zip uniq compact flatten
52
+ to_a to_ary to_h to_hash keys values values_at fetch dig slice
53
+ transform_values transform_keys invert drop take_while drop_while
54
+ reverse sample shuffle rotate chunk_while each_slice each_cons
55
+ to_s to_i to_f to_r to_d to_sym inspect round abs ceil floor
56
+ divmod fdiv clamp between? positive? negative? zero? nonzero? nan? infinite?
57
+ upcase downcase capitalize titleize humanize pluralize singularize
58
+ strip chomp split join gsub sub start_with? end_with? match match? scan
59
+ present blank squish truncate_words
60
+ ago from_now since until seconds second minutes minute hours hour
61
+ days day weeks week fortnight months month years year
62
+ beginning_of_day end_of_day midnight noon beginning_of_hour end_of_hour
63
+ beginning_of_minute end_of_minute
64
+ beginning_of_week end_of_week beginning_of_month end_of_month
65
+ beginning_of_quarter end_of_quarter beginning_of_year end_of_year
66
+ all_day all_week all_month all_quarter all_year
67
+ today yesterday tomorrow current now at parse iso8601 strftime
68
+ to_date to_time to_datetime in_time_zone utc localtime change advance
69
+ prev_day next_day prev_week next_week prev_month next_month
70
+ prev_year next_year monday sunday wday yday cweek on_weekend? on_weekday?
71
+ new_offset zone
72
+ sql cast lower upper
73
+ rand hex uuid alphanumeric random_number
74
+ attributes attribute_names as_json to_json column_names table_name
75
+ primary_key model_name human_attribute_name arel_table type_for_attribute
76
+ puts pp print p format sprintf tap then yield_self lambda proc
77
+ ].to_set.freeze
78
+
79
+ CONSTRUCTORS = %w[new build].to_set.freeze
80
+
81
+ UNSAFE_LOAD_RECEIVERS = %w[
82
+ Kernel Object Module Class Marshal YAML Psych JSON ERB File IO Dir
83
+ ].to_set.freeze
84
+
85
+ BLOCKED_CONSTANTS = (UNSAFE_LOAD_RECEIVERS + %w[
86
+ ENV Process Open3 Socket Net Shellwords Tempfile Pathname Gem Bundler
87
+ Sidekiq ActionMailer
88
+ ]).to_set.freeze
89
+
90
+ VALUE_CONSTANTS = %w[
91
+ Time Date DateTime TimeZone Range Hash Array Set String Symbol
92
+ Integer Float Rational BigDecimal Struct Arel
93
+ ].to_set.freeze
94
+
95
+ SAFE_CONSTANTS = %w[
96
+ Time Date DateTime ActiveSupport TimeZone Arel Float Integer Numeric String
97
+ Symbol Hash Array Range Set Math Rational BigDecimal Comparable Struct
98
+ SecureRandom ActiveRecord Base Relation Rails
99
+ ].to_set.freeze
100
+
101
+ DANGEROUS_SQL = /
102
+ \b(delete\s+from|drop\s+(table|database|index|schema)|truncate\s+table|
103
+ insert\s+into|update\s+\w+\s+set|alter\s+table|grant\s+|create\s+table)\b |
104
+ ;\s*(select|delete|drop|update|insert|alter|attach)\b
105
+ /ix
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ class QueryValidator
5
+ module SensitiveColumns
6
+ PATTERN = /\A(?:\w*password\w*|\w+_digest|(?:\w+_)?token|\w*secret\w*|(?:\w+_)?api_key|otp_\w+)\z/i
7
+ PRESENCE_TEST = /(?<![\w.:])\w+:\s*nil\b(?!\s*\.)/
8
+ TOKENS = %i[on_ident on_label on_tstring_content on_symbol].freeze
9
+
10
+ module_function
11
+
12
+ def named_in(code)
13
+ Ripper.lex(code.gsub(PRESENCE_TEST, "")).flat_map { |(_, type, text)| TOKENS.include?(type) ? words(text) : [] }
14
+ .grep(PATTERN).uniq
15
+ end
16
+
17
+ def words(text)
18
+ text.scan(/[A-Za-z_]\w*/)
19
+ end
20
+ end
21
+ end
22
+ end