rails-ai-context 5.35.1 → 5.35.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/CONTEXT.md +2 -2
  4. data/CONTRIBUTING.md +1 -1
  5. data/docs/COMPATIBILITY.md +5 -0
  6. data/docs/RELEASING.md +3 -1
  7. data/docs/SECURITY.md +60 -32
  8. data/docs/TOOLS.md +20 -8
  9. data/docs/TROUBLESHOOTING.md +33 -6
  10. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +3 -3
  11. data/lib/rails_ai_context/database_family/mysql_runtime.rb +2 -3
  12. data/lib/rails_ai_context/database_family/postgresql_plan.rb +30 -15
  13. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +2 -2
  14. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +39 -11
  15. data/lib/rails_ai_context/database_family.rb +9 -5
  16. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  17. data/lib/rails_ai_context/introspectors/job_introspector.rb +6 -6
  18. data/lib/rails_ai_context/introspectors/model_introspector.rb +5 -12
  19. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +10 -1
  20. data/lib/rails_ai_context/introspectors/performance_introspector.rb +0 -1
  21. data/lib/rails_ai_context/payload.rb +13 -3
  22. data/lib/rails_ai_context/secret_columns.rb +65 -39
  23. data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
  24. data/lib/rails_ai_context/sql_gate.rb +68 -14
  25. data/lib/rails_ai_context/tools/get_context.rb +4 -4
  26. data/lib/rails_ai_context/tools/get_controllers.rb +5 -1
  27. data/lib/rails_ai_context/tools/onboard.rb +3 -6
  28. data/lib/rails_ai_context/tools/query.rb +1 -1
  29. data/lib/rails_ai_context/tools/runtime_info.rb +8 -4
  30. data/lib/rails_ai_context/tools/safe_call.rb +2 -3
  31. data/lib/rails_ai_context/tools/session_context.rb +2 -3
  32. data/lib/rails_ai_context/version.rb +1 -1
  33. data/server.json +1 -1
  34. metadata +1 -1
@@ -19,11 +19,20 @@ module RailsAiContext
19
19
  sharding: detect_sharding,
20
20
  model_connections: detect_model_connections,
21
21
  multi_db: dbs.size > 1
22
- }.merge({ example_adapters: example_adapters, primary_name: primary_name }.compact)
22
+ }.merge(from_configuration)
23
+ end
24
+
25
+ # What the section says of each database by name, read from configuration alone.
26
+ def listing
27
+ { databases: discover_databases }.merge(from_configuration)
23
28
  end
24
29
 
25
30
  private
26
31
 
32
+ def from_configuration
33
+ { example_adapters: example_adapters, primary_name: primary_name }.compact
34
+ end
35
+
27
36
  # The primary's database.yml name where it is not "primary": a generated file tells it from a database that is.
28
37
  def primary_name
29
38
  name = RailsAiContext::AppDatabases.for(root).primary&.name.to_s
@@ -75,7 +75,6 @@ module RailsAiContext
75
75
  foreign_keys: tables.values.flat_map { |table| Array(table[:foreign_keys]).map { |fk| { from: fk[:from_table], to: fk[:to_table], column: fk[:column] } } } }
76
76
  end
77
77
 
78
- # Why a database with no dump on disk has none.
79
78
  def no_dump_reason(db)
80
79
  return "its database.yml entry turns `database_tasks` off, so Rails writes it no dump" unless db.tasks
81
80
  return "its database.yml entry turns `schema_dump` off, so Rails writes it no dump" unless db.settings["schema_dump"]
@@ -135,18 +135,28 @@ module RailsAiContext
135
135
  words ? " (#{words})" : ""
136
136
  end
137
137
 
138
+ # The multi_database section. A payload that holds none (a preset without it) gets the same list
139
+ # from the app databases, so no mark or name depends on the preset.
140
+ def multi_database(context)
141
+ section(context, :multi_database) || Introspectors::MultiDatabaseIntrospector.new(RailsAiContext.default_app).listing
142
+ end
143
+
144
+ def databases(context)
145
+ Array(multi_database(context)[:databases]).grep(Hash)
146
+ end
147
+
138
148
  def named_scope_words(context, name)
139
- scope_words(list(context, :multi_database, :databases).grep(Hash).find { |database| database[:name].to_s == name.to_s })
149
+ scope_words(databases(context).find { |database| database[:name].to_s == name.to_s })
140
150
  end
141
151
 
142
152
  # What a generated file calls the primary: its own name where another database is the one named primary.
143
153
  def primary_label(context)
144
- name = section(context, :multi_database)&.dig(:primary_name)
154
+ name = multi_database(context)[:primary_name]
145
155
  name && database_named?(context, "primary") ? name.to_s : "primary"
146
156
  end
147
157
 
148
158
  def database_named?(context, name)
149
- list(context, :multi_database, :databases).grep(Hash).any? { |database| database[:name].to_s == name }
159
+ databases(context).any? { |database| database[:name].to_s == name }
150
160
  end
151
161
 
152
162
  # The mark for a database known by its database.yml name.
@@ -36,10 +36,11 @@ module RailsAiContext
36
36
  # A wildcard in a select list. `count(*)` reads no column, so it is left out.
37
37
  WILDCARD = /(?:\b(?:SELECT|DISTINCT|ALL)\b|[,.])\s*\*/i
38
38
 
39
- # A name as a statement writes it before a dot: in backticks, double quotes or brackets, or bare.
40
- QUOTED_NAME = /`(?:``|[^`])+`|"(?:""|[^"])+"|\[[^\]]+\]/
39
+ # A name before a dot, matched in reversed text from the dot backward: bare, or in backticks or double quotes
40
+ # with the quote doubled inside. A schema name on MySQL, MariaDB or PostgreSQL is 64 characters at most.
41
+ NAME_END = /\.(?=\s*(?:[\p{Word}$`"]|[^\x00-\x7F]))/
41
42
  BARE_NAME = /(?:[\p{Word}$]|[^\x00-\x7F])+/
42
- CLOSERS = { "`" => "`", '"' => '"', "[" => "]" }.freeze
43
+ QUOTED_NAME = /`((?>(?:``|[^`]){1,64}))`(?!`)|"((?>(?:""|[^"]){1,64}))"(?!")/
43
44
 
44
45
  # How many times the schemas a statement and the view definitions name are followed into more view definitions.
45
46
  ROUNDS = 3
@@ -60,9 +61,8 @@ module RailsAiContext
60
61
  statement.to_s.strip.empty? ? secrets : secrets.cover(statement)
61
62
  end
62
63
 
63
- # A name or a statement as the databases compare names: case folded. MySQL reads a dotted capital I
64
- # as i, where Ruby's downcase keeps the dot as a combining mark. Runs of spaces are squeezed in both,
65
- # so a name is found in text whether or not the text was squeezed before it came here.
64
+ # A name or a statement as the databases compare names: case folded, a dotted capital I read as i (MySQL does,
65
+ # Ruby's downcase keeps the dot), and runs of spaces squeezed, so a name is found in text squeezed or not.
66
66
  def self.fold(text)
67
67
  text.to_s.downcase.delete("\u0307").squeeze(" ")
68
68
  end
@@ -70,7 +70,7 @@ module RailsAiContext
70
70
  # Matches the name as a whole identifier in folded text, whatever characters it holds and however it is quoted.
71
71
  def self.identifier(name)
72
72
  name = fold(name)
73
- spellings = [ name, name.gsub('"', '""'), name.gsub("`", "``") ].uniq.map { |spelling| Regexp.escape(spelling) }
73
+ spellings = [ name, name.gsub('"', '""'), name.gsub("`", "``"), name.gsub("'", "''") ].uniq.map { |spelling| Regexp.escape(spelling) }
74
74
  /(?<!\p{Word})(?:#{spellings.join("|")})(?!\p{Word})/
75
75
  end
76
76
 
@@ -171,6 +171,7 @@ module RailsAiContext
171
171
  @index_tables = {}
172
172
  @asked = Set.new
173
173
  @read = []
174
+ @spelled = {}
174
175
  learn(relations: relations, views: views, traced_views: traced_views, generated: generated, index_tables: index_tables)
175
176
  end
176
177
 
@@ -182,8 +183,7 @@ module RailsAiContext
182
183
  reach(SqlGate.comment_free(statement, DatabaseFamily.of(@connection)))
183
184
  end
184
185
 
185
- # Reads the schemas given and the ones the text names, then the ones the definitions of the views it names
186
- # name, and so on through the views those name, ROUNDS reads deep. A view no text names is not followed.
186
+ # Reads the schemas given and those the text names, then follows the views the text names ROUNDS definitions deep.
187
187
  # @raise [Unreadable] when a view the text names leads deeper, or a schema's columns cannot be read
188
188
  def reach(text, schemas = [])
189
189
  return self unless @connection
@@ -253,12 +253,12 @@ module RailsAiContext
253
253
  # column outside the statement, where no check on the statement reads it.
254
254
  def refused_view_in(text)
255
255
  down = self.class.fold(text)
256
- @refused_views.find { |view, _why| names?(down, view) }
256
+ view, why = @refused_views.find { |name, _why| names?(down, name) }
257
+ [ spelled(view), why ] if view
257
258
  end
258
259
 
259
- # Why the view at exactly this place is refused, or nil. PostgreSQL's plan names a relation as `schema.relation`,
260
- # so a table is not taken for a view of its name in another schema. Views known by name alone (their
261
- # definitions could not be read) are told by the bare name.
260
+ # Why the view at exactly this place is refused, or nil. The plan names a relation as `schema.relation`, so a table
261
+ # is not taken for a view of its name in another schema; a view with no definition read is told by its bare name.
262
262
  def placed_view_refusal(relation, name)
263
263
  placed, bare = self.class.fold(relation), self.class.fold(name)
264
264
  return @refused_views[placed] if @views&.key?(placed)
@@ -276,13 +276,15 @@ module RailsAiContext
276
276
  # table, and a virtual table or its vocabulary when the virtual table holds a secret column.
277
277
  def unread_table_in(text)
278
278
  down = self.class.fold(text)
279
- @unread_tables.find { |table, _why| names?(down, table) }
279
+ table, why = @unread_tables.find { |name, _why| names?(down, name) }
280
+ [ spelled(table), why ] if table
280
281
  end
281
282
 
282
283
  # The first relation holding a secret column that the statement's text names, or nil.
283
284
  def relation_named_in(text)
284
285
  down = self.class.fold(text)
285
- @by_relation.each_key.find { |relation| names?(down, relation) }
286
+ relation = @by_relation.each_key.find { |name| names?(down, name) }
287
+ relation && spelled(relation)
286
288
  end
287
289
 
288
290
  # The first secret name the statement's text names, or nil. Case-insensitive
@@ -306,19 +308,16 @@ module RailsAiContext
306
308
 
307
309
  private
308
310
 
309
- # Every name that stands before a dot: a schema, a table or an alias. One pass over the text, since a
310
- # regexp that finds them is quadratic on a Ruby with no match cache; an opener with no closer is no name.
311
+ # Every name that stands before a dot, read backward from the dot, so a quote character inside a string before it
312
+ # changes nothing. A name in brackets is not read: SQLite reads every schema, and the gate refuses a bracket on MySQL.
311
313
  def dotted_names(text)
312
- scanner = StringScanner.new(text)
313
- bytes = text.b
314
- last = CLOSERS.transform_values { |closer| bytes.rindex(closer) || -1 }
314
+ scanner = StringScanner.new(text.reverse)
315
315
  names = []
316
- until scanner.eos?
317
- quoted = last.fetch(scanner.peek(1), -1) > scanner.pos ? scanner.scan(QUOTED_NAME) : nil
318
- name = quoted ? quoted[1..-2] : scanner.scan(BARE_NAME)
319
- next scanner.getch unless name
320
-
321
- names << name.gsub(/([`"])\1/, '\1') if scanner.match?(/\s*\./)
316
+ while scanner.skip_until(NAME_END)
317
+ scanner.skip(/\s+/)
318
+ if (bare = scanner.check(BARE_NAME)) then names << bare.reverse
319
+ elsif scanner.check(QUOTED_NAME) then names << (scanner[1]&.gsub("``", "`") || scanner[2].gsub('""', '"')).reverse
320
+ end
322
321
  end
323
322
  names
324
323
  end
@@ -344,12 +343,18 @@ module RailsAiContext
344
343
  def too_deep(found, followed, family)
345
344
  found = found.map { |schema| self.class.fold(schema) }
346
345
  view = followed.find { |name, _start| (dotted_names(self.class.fold(@views[name])) & found).any? }&.last
347
- Unreadable.new("The view `#{view}` leads through more #{family.postgresql? ? "schemas" : "databases"} than rails_query follows " \
348
- "(#{ROUNDS} rounds), so the secret columns it could read cannot be told and no statement that names it runs.")
346
+ Unreadable.new("The view `#{spelled(view)}` leads through more #{family.postgresql? ? "schemas" : "databases"} than rails_query follows, " \
347
+ "so the secret columns it could read cannot be told and no statement that names it runs.")
348
+ end
349
+
350
+ # The name as the database spells it, for a sentence: the rule itself holds every name folded.
351
+ def spelled(name)
352
+ @spelled.fetch(name, name)
349
353
  end
350
354
 
351
355
  def learn(relations:, views: nil, traced_views: [], generated: {}, index_tables: {})
352
356
  fold = self.class.method(:fold)
357
+ [ relations, views, index_tables ].each { |named| named&.each_key { |name| @spelled[fold.call(name)] ||= name.to_s } }
353
358
  relations.each { |relation, names| @columns[fold.call(relation)].concat(Array(names).map(&fold)) }
354
359
  generated.each do |relation, expressions|
355
360
  @columns[fold.call(relation)].concat(expressions.keys.map(&fold))
@@ -414,7 +419,7 @@ module RailsAiContext
414
419
  end
415
420
  return refused if nested.empty?
416
421
 
417
- nested.each { |view, inner| refused[view] = "it reads the view `#{inner}`: #{refused[inner]}" }
422
+ nested.each { |view, inner| refused[view] = "it reads the view `#{spelled(inner)}`: #{refused[inner]}" }
418
423
  end
419
424
  end
420
425
 
@@ -438,23 +443,44 @@ module RailsAiContext
438
443
  end
439
444
  end
440
445
 
441
- # The value of a full-text table's `content=` option, quoted or bare.
442
- CONTENT_OPTION = /\bcontent\s*=\s*("(?:""|[^"])*"|'(?:''|[^'])*'|`(?:``|[^`])*`|\[[^\]]*\]|[^\s,)]+)/i
446
+ # A name or a string in any of the four quotings SQLite reads.
447
+ SQLITE_QUOTED = /"(?:""|[^"])*"|'(?:''|[^'])*'|`(?:``|[^`])*`|\[[^\]]*\]/
448
+
449
+ # One argument of a virtual table's module: up to the next comma outside quotes, brackets and parentheses.
450
+ MODULE_ARGUMENT = /(?:#{SQLITE_QUOTED}|\([^()]*\)|[^,])+/
443
451
 
444
- # virtual table => why it is not read, for each one whose definition names a refused view or a table that
445
- # is not read: its rows, or its words, are theirs. A definition names a relation in its `content=` option
446
- # and, for a vocabulary, as its table: any other word of it is a column or an option of its own.
452
+ # virtual table => why it is not read, for each one built on a refused view or on a table that is not read:
453
+ # its rows, or its words, are theirs.
447
454
  def built_on_unread(index_tables)
448
455
  index_tables.each_with_object({}) do |(table, (kind, owner, definition)), built|
449
456
  table = self.class.fold(table)
450
457
  next if definition.nil? || @unread_tables.key?(table)
451
458
 
452
- down = self.class.fold(kind == :vocabulary ? owner : definition.scan(CONTENT_OPTION).join(" "))
459
+ down = self.class.fold("#{owner if kind == :vocabulary} #{relation_words(table, definition)}")
453
460
  view, why = @refused_views.find { |name, _why| name != table && names?(down, name) }
454
461
  other = @unread_tables.keys.find { |name| name != table && names?(down, name) } unless view
455
- built[table] = "its definition names the view `#{view}`, which is not queried: #{why}" if view
456
- built[table] = "its definition names `#{other}`, which is not read" if other
462
+ built[table] = "its definition names the view `#{spelled(view)}`, which is not queried: #{why}" if view
463
+ built[table] = "its definition names `#{spelled(other)}`, which is not read" if other
464
+ end
465
+ end
466
+
467
+ # The name an argument of a virtual table's module opens with: one quoted name whole, or its first word.
468
+ LEADING_NAME = /\A\s*(?:#{SQLITE_QUOTED}|[^\s"'`\[]+)/
469
+
470
+ # What a virtual table's definition can name a relation with: its module, and every argument but the columns a
471
+ # full-text table declares (a column named like a view is no view). An argument with `=` is an option, kept whole.
472
+ def relation_words(table, definition)
473
+ mod, arguments = definition.match(/\A\s*USING\s+([^\s(]+)\s*(?:\((.*)\))?\s*\z/mi)&.captures
474
+ return definition unless mod
475
+
476
+ mod_name = mod.delete(%q("'`[]))
477
+ columns = mod_name.match?(/\Afts[345]\z/i) ? @columns[table] : []
478
+ named = arguments.to_s.scan(MODULE_ARGUMENT).reject do |argument|
479
+ !argument.include?("=") && columns.include?(self.class.fold(argument[LEADING_NAME].to_s.delete(%q("'`[])).strip))
457
480
  end
481
+ # A vocabulary's last argument is its type (row, col or instance), which SQLite reads as no relation.
482
+ named.pop if mod_name.casecmp?("fts5vocab") && named.size > 1
483
+ [ mod, *named ].join(" ")
458
484
  end
459
485
 
460
486
  def refusal_of(definition)
@@ -463,9 +489,9 @@ module RailsAiContext
463
489
  elsif (column = named_in(definition))
464
490
  "its definition names the secret column `#{column}`"
465
491
  elsif (table = @unread_tables.keys.find { |name| names?(definition, name) })
466
- "its definition reads `#{table}`, which is not read"
492
+ "its definition names `#{spelled(table)}`, which is not read"
467
493
  elsif definition.match?(WILDCARD) && (relation = @by_relation.keys.find { |name| names?(definition, name) })
468
- "its definition has a wildcard and names `#{relation}`, which holds the secret column `#{@by_relation[relation].first}`"
494
+ "its definition has a wildcard and names `#{spelled(relation)}`, which holds the secret column `#{@by_relation[relation].first}`"
469
495
  end
470
496
  end
471
497
 
@@ -512,7 +512,7 @@ module RailsAiContext
512
512
  end
513
513
 
514
514
  if data[:recent]&.any?
515
- lines << "### Recent Migrations#{": primary" if secondary.any?}"
515
+ lines << "### Recent Migrations#{": #{Payload.primary_label(context)}" if secondary.any?}"
516
516
  data[:recent].each do |m|
517
517
  actions = m[:actions]&.any? ? " (#{m[:actions].join(', ')})" : ""
518
518
  lines << "- `#{m[:version]}` #{m[:name]}#{actions}"
@@ -38,8 +38,8 @@ module RailsAiContext
38
38
  end
39
39
  end
40
40
 
41
- # The checks read a megabyte in two seconds at the slowest (a nest of parentheses, a run of dollar signs), and
42
- # the longest statement written by hand is far under it: 100,000 ids in an IN list are 700 KB.
41
+ # The longest statement written by hand is far under it: 100,000 ids in an IN list are 700 KB. The checks on a
42
+ # statement's text have text_timeout seconds to read one.
43
43
  MAX_BYTES = 1_000_000
44
44
 
45
45
  # The text checks ran past the time they have.
@@ -64,7 +64,7 @@ module RailsAiContext
64
64
  # refused by the CREATE keyword - SHOW CREATE USER prints credential hashes.
65
65
  SHOW_CREATE_DEFINITION = /\A\s*SHOW\s+CREATE\s+(?:TABLE|VIEW)\b/i
66
66
  BLOCKED_CLAUSES = /\bFOR\s+(?:UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b|\bLOCK\s+IN\s+SHARE\s+MODE\b/i
67
- # The lists below hold each name under the family that has it, so a name is refused where it can be read.
67
+ # A list keyed by family holds each name under the family that has it, so a name is refused where it can be read.
68
68
 
69
69
  # Other sessions' statements, replication hosts and users, and the row data a log holds.
70
70
  BLOCKED_SHOWS = { mysql: /\bSHOW\s+(?:(?:FULL|ALL)\s+)?(?:GRANTS|PROCESSLIST|BINLOG|BINARY|RELAYLOG|SLAVES?|MASTER|REPLICAS?|ENGINE|
@@ -161,6 +161,10 @@ module RailsAiContext
161
161
  master_pos_wait | source_pos_wait
162
162
  )\s*\(/ix }.freeze
163
163
 
164
+ # A value either one sets stays on the pooled connection, in or out of a read-only transaction.
165
+ VARIABLE_ASSIGNMENT = { mysql: /:=/ }.freeze
166
+ SETS_LAST_INSERT_ID = { mysql: /\blast_insert_id\s*\(\s*[^\s)]/i }.freeze
167
+
164
168
  # VOLATILE built-ins that only read: pg_proc marks a reader of live server
165
169
  # state VOLATILE too. Any other VOLATILE function in the plan is refused.
166
170
  PG_VOLATILE_ALLOWLIST = %w[
@@ -251,9 +255,8 @@ module RailsAiContext
251
255
  end
252
256
  end
253
257
 
254
- # family decides the lexing and whether the plan checks run. budget is the seconds the reads the
255
- # gate sends (the plan, the pg_proc lookup) share with the caller's other reads. text_timeout is the seconds
256
- # the checks on the statement's text have to themselves.
258
+ # budget: the seconds the reads the gate sends (the plan, the catalog lookups) share with the caller's other reads.
259
+ # text_timeout: the seconds the checks on the statement's text have to themselves.
257
260
  def initialize(family:, secrets:, connection:, budget:, text_timeout: RailsAiContext.configuration.query_timeout)
258
261
  @text_timeout = text_timeout
259
262
  @family = family
@@ -321,9 +324,16 @@ module RailsAiContext
321
324
  end
322
325
  end
323
326
 
324
- # The comment that opens where the scanner stands, as the family reads it, or nil.
327
+ # Skips the comment that opens where the scanner stands, as the family reads it. One that never closes runs to the end.
328
+ # @return [Boolean] whether one opened there
325
329
  def self.comment(scanner, family)
326
- scanner.scan(/\/\*.*?(?:\*\/|\z)/m) || scanner.scan(family.line_comment)
330
+ return !scanner.skip(family.line_comment).nil? unless scanner.skip(/\/\*/)
331
+
332
+ depth = 1
333
+ marks = family.nested_comments? ? /\/\*|\*\// : /\*\//
334
+ depth += scanner.matched == "/*" ? 1 : -1 while depth.positive? && scanner.skip_until(marks)
335
+ scanner.terminate if depth.positive?
336
+ true
327
337
  end
328
338
 
329
339
  private
@@ -372,6 +382,11 @@ module RailsAiContext
372
382
  # file functions stay on the raw text: no read-only transaction stops them.
373
383
  masked = mask_quoted(cleaned)
374
384
  return "Blocked: multiple statements (no semicolons)" if masked.match?(MULTI_STATEMENT)
385
+ # MariaDB in its MSSQL mode reads [name] as a quoted name, and no statement of this family holds a bracket otherwise.
386
+ if @family.mysql? && masked.include?("[")
387
+ return "Blocked: a `[` outside a string or a quoted name is not run. MariaDB reads it as the start of a quoted name when the " \
388
+ "session's sql_mode holds MSSQL, and rails_query does not read the session's mode. Quote a name in backticks."
389
+ end
375
390
  # PostgreSQL's read-only transaction refuses a locking read, so a literal there is data.
376
391
  # MySQL's runs FOR SHARE and LOCK IN SHARE MODE, so there the raw text decides.
377
392
  locking = @family.refuses_locking_reads? ? masked : cleaned
@@ -388,7 +403,11 @@ module RailsAiContext
388
403
  return "Blocked: SELECT INTO OUTFILE / DUMPFILE writes to disk" if cleaned.match?(BLOCKED_OUTPUT)
389
404
  # No second statement is left by here, so an INTO after a SELECT belongs to it.
390
405
  select = masked.index(/\bSELECT\b/i)
391
- return "Blocked: SELECT INTO creates a table" if select && masked.rindex(/\bINTO\b/i).to_i > select
406
+ if select && masked.rindex(/\bINTO\b/i).to_i > select
407
+ # After a WITH clause the INTO can be a write's own.
408
+ write = masked[/\b(?:INSERT|MERGE|REPLACE(?!\s*\())\b(?=[^;]*\bINTO\b)/i]
409
+ return write ? "Blocked: contains #{write}" : "Blocked: SELECT INTO creates a table"
410
+ end
392
411
 
393
412
  if (m = listed(BLOCKED_FUNCTIONS, cleaned))
394
413
  return "Blocked: dangerous function #{m[0]} (filesystem/network primitive)"
@@ -428,6 +447,14 @@ module RailsAiContext
428
447
  return "Blocked: session function #{m[0].sub(/\s*\(\z/, "")} leaves state (a named lock) on the pooled " \
429
448
  "connection after the query returns. rails_query will not run it."
430
449
  end
450
+ if listed(VARIABLE_ASSIGNMENT, masked)
451
+ return "Blocked: a user variable assignment (`:=`) is not run. It leaves a value on the pooled connection after the query " \
452
+ "returns, which a read-only transaction does not stop."
453
+ end
454
+ if listed(SETS_LAST_INSERT_ID, cleaned)
455
+ return "Blocked: LAST_INSERT_ID with an argument is not run. It sets a value that stays on the pooled connection after the " \
456
+ "query returns, which a read-only transaction does not stop. LAST_INSERT_ID() with no argument runs."
457
+ end
431
458
 
432
459
  tokens = tokens(cleaned)
433
460
  if renaming_alias_list?(tokens)
@@ -590,8 +617,8 @@ module RailsAiContext
590
617
  "A model declares `#{offending}` with `encrypts`, so config.query_allowed_columns does not release it."
591
618
  end
592
619
  else
593
- "#{"#{reads}. " if source}If `#{offending}` is not secret in your app, add \"#{offending}\" to " \
594
- "config.query_allowed_columns in an initializer."
620
+ "#{"#{reads}. " if source}If `#{offending}` is not secret in your app, add \"#{offending}\" to query_allowed_columns in " \
621
+ ".rails-ai-context.yml (or in config/initializers/rails_ai_context.rb where the app bundles the gem)."
595
622
  end
596
623
  "Blocked: query references secret column `#{offending}`. " \
597
624
  "Name the columns you need; `#{offending}` is secret and never " \
@@ -773,9 +800,8 @@ module RailsAiContext
773
800
  end
774
801
  end
775
802
 
776
- # :star for a select-list wildcard inside parentheses, :table for `TABLE t` there on a family that has the
777
- # statement, :in for SQLite's `x IN t` anywhere, else nil. `count(*)` reads no column and EXISTS returns
778
- # none, so both are left alone.
803
+ # :star for a select-list wildcard inside parentheses, :table for `TABLE t` there off SQLite, :in for SQLite's
804
+ # `x IN t` anywhere, else nil. `count(*)` reads no column and EXISTS returns none, so both are left alone.
779
805
  def subquery_wildcard(tokens)
780
806
  enclosing = []
781
807
  tokens.each_with_index do |token, i|
@@ -824,6 +850,8 @@ module RailsAiContext
824
850
  outputs.concat(Array(node["Output"]).map { |e| e.to_s.gsub(PLAN_LITERAL, "''") })
825
851
  PLAN_COND_KEYS.each { |k| conds.concat(Array(node[k]).map { |e| e.to_s.gsub(PLAN_LITERAL, "''") }) }
826
852
  end
853
+ foreign = plan.nodes.select { |node| node["Remote SQL"] }
854
+ conds.concat(foreign.map { |node| remote_uses(node) }, local_names(foreign))
827
855
  # EXPLAIN prints no row of a VALUES list and no LIMIT, so the functions the text names are read too.
828
856
  called = function_names(raw.map { |expr| expr.gsub(PLAN_LITERAL, "''") } + [ mask_sql_literals(text) ])
829
857
 
@@ -863,6 +891,32 @@ module RailsAiContext
863
891
  end
864
892
  end
865
893
 
894
+ # What the statement a foreign scan sends does with a column past fetching it: its joins, conditions and sorts.
895
+ # GROUP BY names a key by its place in the select list, so a grouped statement is read whole.
896
+ def remote_uses(node)
897
+ remote = node["Remote SQL"].to_s.gsub(PLAN_LITERAL, "''")
898
+ past_select = remote.sub(/\ASELECT\b.*?\bFROM\b/m, "")
899
+ past_select.match?(/\bGROUP\s+BY\b/i) ? remote : past_select
900
+ end
901
+
902
+ # The foreign columns a remote statement uses under the name their column_name option gives them, by their own
903
+ # names. A join or an aggregate the remote server runs names its tables under "Relations".
904
+ def local_names(foreign)
905
+ return [] if foreign.empty?
906
+
907
+ renamed = @budget.spend do |left|
908
+ @family.bounded(@connection, timeout: left) do
909
+ @connection.select_rows(
910
+ "SELECT c.relname, a.attname, o.option_value FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid " \
911
+ "CROSS JOIN LATERAL pg_options_to_table(a.attfdwoptions) o WHERE o.option_name = 'column_name' AND NOT a.attisdropped"
912
+ )
913
+ end
914
+ end
915
+ renamed.filter_map do |table, local, far|
916
+ local if foreign.any? { |node| names?(node.values_at("Relation Name", "Relations").map(&:to_s), table) && names?([ remote_uses(node) ], far) }
917
+ end
918
+ end
919
+
866
920
  # A secret name the text check passed as a literal runs only when a plan cleared the checks,
867
921
  # so the plan backs a masker that a quote trick fooled.
868
922
  def unvouched_literal_refusal(text, plan)
@@ -167,7 +167,7 @@ module RailsAiContext
167
167
  return nil if RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, name, action.to_s,
168
168
  file: Payload.controller_file(ctx, name), controllers: controllers)
169
169
 
170
- "Action '#{echo_input(action)}' not found in #{name}. Available: #{actions.join(', ')}"
170
+ GetControllers.missing_action_message(name, action, actions)
171
171
  end
172
172
 
173
173
  # The templates for a controller, read from the directory Rails would
@@ -354,7 +354,7 @@ module RailsAiContext
354
354
  analyze_result = compose(AnalyzeFeature, feature: feature_name)
355
355
  parts = [ response_text(analyze_result) ]
356
356
  related = []
357
- named = []
357
+ matched_models = []
358
358
 
359
359
  # Enrich with schema columns for matching models
360
360
  ctx = begin; cached_context; rescue; nil; end
@@ -364,7 +364,7 @@ module RailsAiContext
364
364
 
365
365
  models.each_key do |model_name|
366
366
  next unless model_name.downcase.include?(feature_name.downcase)
367
- named << model_name
367
+ matched_models << model_name
368
368
  table_name = models[model_name][:table_name]
369
369
  next unless table_name
370
370
  matched_tables << table_name
@@ -396,7 +396,7 @@ module RailsAiContext
396
396
  return text unless text.empty? && !own_served?(AnalyzeFeature)
397
397
 
398
398
  # With rails_analyze_feature off, only model and controller names were matched.
399
- return "The model names that hold '#{echo_input(feature_name)}': #{named.sort.join(', ')}." if named.any?
399
+ return "The model names that hold '#{echo_input(feature_name)}': #{matched_models.sort.join(', ')}." if matched_models.any?
400
400
 
401
401
  "No matches found for '#{echo_input(feature_name)}'. No model or controller name holds this keyword.#{AnalyzeFeature.model_names_hint(ctx)}"
402
402
  rescue => e
@@ -287,6 +287,10 @@ module RailsAiContext
287
287
  end
288
288
  end
289
289
 
290
+ def self.missing_action_message(controller_name, action_name, actions)
291
+ "Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}"
292
+ end
293
+
290
294
  private_class_method def self.format_action_source(controller_name, info, action_name)
291
295
  # An alias is the same class: its actions are read from the controller it names.
292
296
  target = info[:alias_of] && RailsAiContext::Payload.controllers(cached_context)[info[:alias_of]]
@@ -304,7 +308,7 @@ module RailsAiContext
304
308
  unlisted = RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, controller_name, action_name,
305
309
  file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
306
310
  unless unlisted
307
- return empty_response("Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}")
311
+ return empty_response(missing_action_message(controller_name, action_name, actions))
308
312
  end
309
313
 
310
314
  action_name = unlisted[:name]
@@ -187,13 +187,10 @@ module RailsAiContext
187
187
  Payload.framework_databases(schema).to_h { |name, info| [ name.to_s, Payload.database_framework(info) ] }
188
188
  end
189
189
 
190
- # The app's own databases besides the primary, as its schema dumps
191
- # declare them: metrics71's analytics database on SQLite went
192
- # uncounted beside its PostgreSQL primary. Each is
193
- # { name:, adapter:, tables:, unread: }, the adapter nil when database.yml does not say,
194
- # `unread` true for a dump that could not be read, whose table count is then no count.
190
+ # The app's own databases besides the primary, as its schema dumps declare them. The adapter is nil when
191
+ # database.yml does not say; `unread` marks a dump that could not be read, whose table count is no count.
195
192
  def own_secondary_databases(ctx, schema)
196
- configured = Array(Payload.section(ctx, :multi_database)&.dig(:databases)).grep(Hash).to_h { |d| [ d[:name].to_s, d ] }
193
+ configured = Payload.databases(ctx).to_h { |d| [ d[:name].to_s, d ] }
197
194
  Payload.app_databases(schema).map do |name, db|
198
195
  { name: name.to_s, adapter: RailsAiContext::SchemaAdapter.database_label(configured[name.to_s]),
199
196
  tables: (db[:total_tables] || db[:tables].size).to_i, unread: db[:unread] }
@@ -242,7 +242,7 @@ module RailsAiContext
242
242
  else
243
243
  "the row limit of #{cap}; pass limit: up to 1000 to see more"
244
244
  end
245
- note = "#{cap} rows shown; the query returned more (#{detail})."
245
+ note = "#{count_phrase(cap, "row")} shown; the query returned more (#{detail})."
246
246
  format == "csv" ? "\n\n#{note}" : "\n\n_#{note}_"
247
247
  end
248
248
 
@@ -123,7 +123,9 @@ module RailsAiContext
123
123
  end
124
124
 
125
125
  sizes = gather_table_sizes(conn, family)
126
- if sizes&.any?
126
+ if sizes == :unread
127
+ lines << "" << "_Table sizes were not read: the read failed or took longer than query_timeout._"
128
+ elsif sizes&.any?
127
129
  lines << "" << "### Table Sizes"
128
130
  lines << "| Table | Size |"
129
131
  lines << "|-------|------|"
@@ -141,7 +143,9 @@ module RailsAiContext
141
143
  # Index usage (full detail only)
142
144
  if RailsAiContext::DetailLevel.full?(detail)
143
145
  index_usage = gather_index_usage(conn, family)
144
- if index_usage.nil? && family.known?
146
+ if index_usage == :unread
147
+ lines << "" << "_Index usage was not read: the read failed or took longer than query_timeout._"
148
+ elsif index_usage.nil? && family.known?
145
149
  lines << "" << "_Index usage is not available for the #{family.name} database family._"
146
150
  elsif index_usage&.any?
147
151
  lines << "" << "### Index Usage"
@@ -171,7 +175,7 @@ module RailsAiContext
171
175
 
172
176
  AppDatabases.bounded(conn) { family.table_sizes(conn) }&.reject { |r| INTERNAL_TABLES.include?(r[:name]) }
173
177
  rescue => e
174
- RailsAiContext.debug_fail(e, nil, label: "gather_table_sizes")
178
+ RailsAiContext.debug_fail(e, :unread, label: "gather_table_sizes")
175
179
  end
176
180
 
177
181
  # Every database Rails migrates here, each through its own connection.
@@ -235,7 +239,7 @@ module RailsAiContext
235
239
  def gather_index_usage(conn, family)
236
240
  AppDatabases.bounded(conn) { family.index_usage(conn) } if family.known?
237
241
  rescue => e
238
- RailsAiContext.debug_fail(e, [], label: "gather_index_usage")
242
+ RailsAiContext.debug_fail(e, :unread, label: "gather_index_usage")
239
243
  end
240
244
 
241
245
  # ── Cache section ────────────────────────────────────────────────
@@ -89,9 +89,8 @@ module RailsAiContext
89
89
  response, left_out = with_left_out_frame { RailsAiContext::RunCache.around { yield } }
90
90
  response = append_note(response, RailsAiContext::Tools::BaseTool.left_out_note(left_out))
91
91
  append_note(response, invalid_detail_note(discarded))
92
- # ScriptError too: a SyntaxError or LoadError out of an app file the
93
- # tool reached must cost that call, not the stdio server and every
94
- # tool with it. So must a stack overflow or a SecurityError.
92
+ # An app file that cannot load, a stack overflow or a SecurityError costs that call,
93
+ # where a StandardError rescue alone would let it end the stdio server and every tool with it.
95
94
  rescue StandardError, ScriptError, SystemStackError, SecurityError => e
96
95
  failure_response(e).tap do |failure|
97
96
  call = Thread.current[:rails_ai_context_call]
@@ -73,9 +73,8 @@ module RailsAiContext
73
73
  [ tool, params ]
74
74
  end
75
75
 
76
- # The tool a mark names, spelled the way the server names it, so a
77
- # mark and the call it stands for are one entry, and the config section
78
- # a retired name stands for. Takes the short forms the CLI takes.
76
+ # The tool a mark names, spelled as the server names it, so a mark and the call it stands for are one
77
+ # entry, with the config section a retired name stands for. Takes the short forms the CLI takes.
79
78
  def known_tool(name)
80
79
  return nil if name.to_s.empty?
81
80
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.35.1"
4
+ VERSION = "5.35.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.35.1",
10
+ "version": "5.35.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.35.1
4
+ version: 5.35.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine