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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +198 -0
- data/CONTEXT.md +2 -2
- data/CONTRIBUTING.md +1 -1
- data/docs/COMPATIBILITY.md +5 -0
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +60 -32
- data/docs/TOOLS.md +20 -8
- data/docs/TROUBLESHOOTING.md +33 -6
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +3 -3
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +2 -3
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +30 -15
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +2 -2
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +39 -11
- data/lib/rails_ai_context/database_family.rb +9 -5
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/job_introspector.rb +6 -6
- data/lib/rails_ai_context/introspectors/model_introspector.rb +5 -12
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +0 -1
- data/lib/rails_ai_context/payload.rb +13 -3
- data/lib/rails_ai_context/secret_columns.rb +65 -39
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
- data/lib/rails_ai_context/sql_gate.rb +68 -14
- data/lib/rails_ai_context/tools/get_context.rb +4 -4
- data/lib/rails_ai_context/tools/get_controllers.rb +5 -1
- data/lib/rails_ai_context/tools/onboard.rb +3 -6
- data/lib/rails_ai_context/tools/query.rb +1 -1
- data/lib/rails_ai_context/tools/runtime_info.rb +8 -4
- data/lib/rails_ai_context/tools/safe_call.rb +2 -3
- data/lib/rails_ai_context/tools/session_context.rb +2 -3
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- 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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
#
|
|
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
|
|
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 { |
|
|
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.
|
|
260
|
-
#
|
|
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 { |
|
|
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 { |
|
|
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
|
|
310
|
-
#
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
"
|
|
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
|
-
#
|
|
442
|
-
|
|
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
|
|
445
|
-
#
|
|
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
|
|
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
|
|
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#{":
|
|
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
|
|
42
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
255
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
777
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}': #{
|
|
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(
|
|
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
|
-
#
|
|
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 =
|
|
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}
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
93
|
-
#
|
|
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
|
|
77
|
-
#
|
|
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
|
|
data/server.json
CHANGED