rails-ai-context 5.35.0 → 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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
@@ -0,0 +1,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "strscan"
5
+
6
+ module RailsAiContext
7
+ # The columns rails_query never returns and whose names a statement may not
8
+ # mention, for one connection's schema. Built once per connection and run.
9
+ class SecretColumns
10
+ # The schema of a database could not be read, so its secret columns cannot be told.
11
+ class Unreadable < RailsAiContext::Error; end
12
+
13
+ DEFAULT_REDACTED_COLUMNS = %w[
14
+ password_digest encrypted_password password_hash
15
+ reset_password_token confirmation_token unlock_token
16
+ otp_secret session_data secret_key
17
+ api_key api_secret access_token refresh_token jti
18
+ ].freeze
19
+
20
+ # PostgreSQL catalog columns that hold a stored credential: a role's password hash, and the settings
21
+ # stored for a role or a database. pg_roles, pg_user and pg_shadow are views over them.
22
+ CATALOG_CREDENTIALS = { "pg_authid" => %w[rolpassword], "pg_db_role_setting" => %w[setconfig] }.freeze
23
+
24
+ # Matched whether or not the name is a real column, so the check holds with no
25
+ # connection. `config.query_allowed_columns` releases a name that only looks secret.
26
+ BUILT_IN_NAMES = (DEFAULT_REDACTED_COLUMNS + %w[
27
+ password_reset_token remember_token secret private_key authentication_string
28
+ ] + CATALOG_CREDENTIALS.values.flatten).freeze
29
+
30
+ HEURISTIC_SUFFIXES = %w[password secret token key digest hash].freeze
31
+
32
+ # Rails' own bookkeeping tables. ar_internal_metadata.key ends in "key": counted,
33
+ # it would refuse every statement whose text holds the word "key".
34
+ BOOKKEEPING_TABLES = %w[ar_internal_metadata schema_migrations].freeze
35
+
36
+ # A wildcard in a select list. `count(*)` reads no column, so it is left out.
37
+ WILDCARD = /(?:\b(?:SELECT|DISTINCT|ALL)\b|[,.])\s*\*/i
38
+
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]))/
42
+ BARE_NAME = /(?:[\p{Word}$]|[^\x00-\x7F])+/
43
+ QUOTED_NAME = /`((?>(?:``|[^`]){1,64}))`(?!`)|"((?>(?:""|[^"]){1,64}))"(?!")/
44
+
45
+ # How many times the schemas a statement and the view definitions name are followed into more view definitions.
46
+ ROUNDS = 3
47
+
48
+ # Cached per run and database: one schema read however many checks ask. With a statement, the schemas it
49
+ # names are read too, so the rule knows every relation the statement can reach.
50
+ def self.for(connection, context: nil, statement: nil)
51
+ secrets = RunCache.fetch([ :secret_columns, database_key(connection) ]) do
52
+ family = DatabaseFamily.of(connection)
53
+ schemas = family.known? ? family.unqualified_schemas(connection) : []
54
+ # The context's schema is the primary's, so no other database falls back to it.
55
+ relations = relations_from(connection, schemas) || (relations_from(context) if primary?(connection))
56
+ raise unreadable(connection) unless relations
57
+
58
+ relations = relations.merge(CATALOG_CREDENTIALS) if family.plan_checks?
59
+ new(relations: relations, encrypted: encrypted_from(context), connection: connection, **definitions_from(connection, schemas))
60
+ end
61
+ statement.to_s.strip.empty? ? secrets : secrets.cover(statement)
62
+ end
63
+
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
+ def self.fold(text)
67
+ text.to_s.downcase.delete("\u0307").squeeze(" ")
68
+ end
69
+
70
+ # Matches the name as a whole identifier in folded text, whatever characters it holds and however it is quoted.
71
+ def self.identifier(name)
72
+ name = fold(name)
73
+ spellings = [ name, name.gsub('"', '""'), name.gsub("`", "``"), name.gsub("'", "''") ].uniq.map { |spelling| Regexp.escape(spelling) }
74
+ /(?<!\p{Word})(?:#{spellings.join("|")})(?!\p{Word})/
75
+ end
76
+
77
+ def self.unreadable(connection)
78
+ Unreadable.new("The columns of the #{connection.pool.db_config.name} database could not be read, so its secret " \
79
+ "columns cannot be told and no query runs on it.")
80
+ end
81
+ private_class_method :unreadable
82
+
83
+ # An error that is the call's own answer, so no other read stands in for the one that raised it.
84
+ def self.answered?(error)
85
+ DatabaseFamily.lost_connection?(error) ||
86
+ (error.is_a?(ActiveRecord::StatementInvalid) && (DatabaseFamily.timed_out?(error) || DatabaseFamily.busy?(error)))
87
+ end
88
+ private_class_method :answered?
89
+
90
+ def self.primary?(connection)
91
+ connection.pool.db_config.name == ActiveRecord::Base.connection_db_config.name
92
+ end
93
+ private_class_method :primary?
94
+
95
+ # What the database stores outside any statement: view definitions and generated column expressions.
96
+ def self.definitions_from(connection, schemas)
97
+ family = DatabaseFamily.of(connection)
98
+ return {} unless family.known?
99
+
100
+ views = begin
101
+ family.view_definitions(connection, schemas)
102
+ rescue ActiveRecord::StatementInvalid => e
103
+ raise if answered?(e)
104
+
105
+ # Named with no definition, so each one is refused as unreadable.
106
+ connection.views.to_h { |view| [ view, nil ] }
107
+ end
108
+ # SQLite stores a definition as written, comments included.
109
+ views = views.transform_values { |definition| definition && SqlGate.comment_free(definition.to_s, family) }
110
+ { views: views, traced_views: family.traced_views(connection, schemas), generated: family.generated_columns(connection, schemas),
111
+ index_tables: family.index_tables(connection, schemas) }
112
+ rescue ActiveRecord::StatementInvalid => e
113
+ raise if answered?(e)
114
+
115
+ raise unreadable(connection)
116
+ end
117
+
118
+ def self.database_key(connection)
119
+ db_config = connection.pool.db_config
120
+ [ db_config.env_name, db_config.name ]
121
+ rescue StandardError
122
+ connection.object_id
123
+ end
124
+ private_class_method :database_key
125
+
126
+ # relation name => column names, from a live connection or a cached
127
+ # context's schema; nil when neither can be read.
128
+ def self.relations_from(source, schemas = [])
129
+ if source.respond_to?(:adapter_name)
130
+ family = DatabaseFamily.of(source)
131
+ return nil unless family.known?
132
+
133
+ family.relation_columns(source, schemas).reject { |table, _columns| BOOKKEEPING_TABLES.include?(table.to_s.downcase) }
134
+ else
135
+ tables = source&.dig(:schema, :tables)
136
+ return nil unless tables.is_a?(Hash) && tables.any?
137
+
138
+ tables.each_with_object({}) do |(table, data), acc|
139
+ next if BOOKKEEPING_TABLES.include?(table.to_s.downcase)
140
+ acc[table.to_s] = Array(data.is_a?(Hash) && data[:columns]).map { |col| col.is_a?(Hash) ? col[:name] : col }.compact
141
+ end
142
+ end
143
+ rescue StandardError => e
144
+ # A read the timeout ended is the call's timeout, so it is no reason to fall back.
145
+ raise if answered?(e)
146
+
147
+ nil
148
+ end
149
+
150
+ def self.encrypted_from(context)
151
+ models = context&.dig(:models)
152
+ return [] unless models.is_a?(Hash)
153
+
154
+ models.each_value.flat_map { |data| data.is_a?(Hash) ? Array(data[:encrypts]) : [] }
155
+ end
156
+ private_class_method :encrypted_from
157
+
158
+ # Each argument is what the DatabaseFamily::Family read of the same name answers. views is nil when the
159
+ # database's views are not known. connection: where more schemas are read from; with none the rule keeps what it was given.
160
+ def initialize(relations: {}, encrypted: [], views: nil, traced_views: [], generated: {}, index_tables: {}, connection: nil,
161
+ config: RailsAiContext.configuration)
162
+ fold = self.class.method(:fold)
163
+ @redacted = (Array(config.query_redacted_columns) + BUILT_IN_NAMES).to_set(&fold)
164
+ @allowed = Array(config.query_allowed_columns).to_set(&fold)
165
+ @encrypted = Array(encrypted).to_set(&fold)
166
+ @connection = connection
167
+ @columns = Hash.new { |hash, relation| hash[relation] = [] }
168
+ @views = views && {}
169
+ @traced = []
170
+ @expressions = {}
171
+ @index_tables = {}
172
+ @asked = Set.new
173
+ @read = []
174
+ @spelled = {}
175
+ learn(relations: relations, views: views, traced_views: traced_views, generated: generated, index_tables: index_tables)
176
+ end
177
+
178
+ # Reads what the statement reaches into the rule.
179
+ # @raise [Unreadable] as #reach does
180
+ def cover(statement)
181
+ return self unless @connection
182
+
183
+ reach(SqlGate.comment_free(statement, DatabaseFamily.of(@connection)))
184
+ end
185
+
186
+ # Reads the schemas given and those the text names, then follows the views the text names ROUNDS definitions deep.
187
+ # @raise [Unreadable] when a view the text names leads deeper, or a schema's columns cannot be read
188
+ def reach(text, schemas = [])
189
+ return self unless @connection
190
+
191
+ family = DatabaseFamily.of(@connection)
192
+ read_schemas(schemas)
193
+ (ROUNDS + 1).times do |round|
194
+ followed = views_reached(text)
195
+ names = [ text, *@views.to_h.values_at(*followed.keys).compact ].flat_map { |read| dotted_names(read) }.to_set - @asked
196
+ found = family.schemas_named(@connection, names.to_a) - @read
197
+ raise too_deep(found, followed, family) if found.any? && round == ROUNDS
198
+
199
+ @asked.merge(names)
200
+ return self if found.empty?
201
+
202
+ read_schemas(found)
203
+ end
204
+ self
205
+ end
206
+
207
+ # Reads the relations, views and generated columns of more schemas into the rule.
208
+ # @raise [Unreadable] when a schema's columns cannot be read
209
+ def read_schemas(schemas)
210
+ schemas = schemas.compact.uniq - @read
211
+ return self if schemas.empty? || @connection.nil?
212
+
213
+ relations = self.class.relations_from(@connection, schemas)
214
+ unless relations
215
+ raise Unreadable, "The columns of #{schemas.map { |name| "`#{name}`" }.join(", ")} could not be read, so the secret columns " \
216
+ "there cannot be told and no query that reads it runs."
217
+ end
218
+ @read.concat(schemas)
219
+ learn(relations: relations, **self.class.definitions_from(@connection, schemas))
220
+ self
221
+ end
222
+
223
+ # Whether the relation's columns were read. A PostgreSQL relation is asked for as `schema.relation`.
224
+ def knows?(relation)
225
+ @columns.key?(self.class.fold(relation))
226
+ end
227
+
228
+ def secret?(name)
229
+ down = self.class.fold(name)
230
+ return false if down.empty?
231
+ return true if encrypted?(down)
232
+ return false if @allowed.include?(down)
233
+
234
+ @generated.key?(down) || @redacted.include?(down) || heuristic?(down) || RailsAiContext::Redaction.secret_name?(down)
235
+ end
236
+
237
+ # True for an `encrypts` column and for a generated column that reads one: neither is released by name.
238
+ # Generated columns of one name on two tables are one name here, encrypted when either reads such a column.
239
+ def encrypted?(name, through = [])
240
+ down = self.class.fold(name)
241
+ return true if @encrypted.include?(down)
242
+
243
+ !through.include?(down) && @generated.fetch(down, []).any? { |source| encrypted?(source, through + [ down ]) }
244
+ end
245
+
246
+ # @return [String, nil] a secret column a generated column's expression reads, an `encrypts` one first
247
+ def generated_from(name)
248
+ sources = @generated.fetch(self.class.fold(name), [])
249
+ sources.find { |source| encrypted?(source) } || sources.first
250
+ end
251
+
252
+ # [view, why] for the first refused view the text names, or nil. A view's definition renames a
253
+ # column outside the statement, where no check on the statement reads it.
254
+ def refused_view_in(text)
255
+ down = self.class.fold(text)
256
+ view, why = @refused_views.find { |name, _why| names?(down, name) }
257
+ [ spelled(view), why ] if view
258
+ end
259
+
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
+ def placed_view_refusal(relation, name)
263
+ placed, bare = self.class.fold(relation), self.class.fold(name)
264
+ return @refused_views[placed] if @views&.key?(placed)
265
+
266
+ @refused_views[bare] if @views&.key?(bare) && @views[bare].nil?
267
+ end
268
+
269
+ # Whether the relation is a view whose rows the plan does not trace to their table columns.
270
+ def stored_view?(relation)
271
+ placed = self.class.fold(relation)
272
+ !@views.nil? && @views.key?(placed) && !@traced.include?(placed)
273
+ end
274
+
275
+ # [table, why] for the first table the text names that is never read, or nil: the file's pages, a shadow
276
+ # table, and a virtual table or its vocabulary when the virtual table holds a secret column.
277
+ def unread_table_in(text)
278
+ down = self.class.fold(text)
279
+ table, why = @unread_tables.find { |name, _why| names?(down, name) }
280
+ [ spelled(table), why ] if table
281
+ end
282
+
283
+ # The first relation holding a secret column that the statement's text names, or nil.
284
+ def relation_named_in(text)
285
+ down = self.class.fold(text)
286
+ relation = @by_relation.each_key.find { |name| names?(down, name) }
287
+ relation && spelled(relation)
288
+ end
289
+
290
+ # The first secret name the statement's text names, or nil. Case-insensitive
291
+ # and word-bounded, so a table or alias that merely holds a secret word passes.
292
+ def named_in(text)
293
+ down = self.class.fold(text)
294
+ @named.each { |name, pattern| return name if down.match?(pattern) }
295
+ nil
296
+ end
297
+
298
+ # The schema's real secret columns, read by the PostgreSQL plan checks.
299
+ attr_reader :in_schema
300
+
301
+ def of_relation(relation)
302
+ @by_relation.fetch(self.class.fold(relation), [])
303
+ end
304
+
305
+ def relation_secret?(relation)
306
+ @by_relation.key?(self.class.fold(relation))
307
+ end
308
+
309
+ private
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.
313
+ def dotted_names(text)
314
+ scanner = StringScanner.new(text.reverse)
315
+ names = []
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
321
+ end
322
+ names
323
+ end
324
+
325
+ # view => the view the text names that leads to it, for each view the text names and each one their
326
+ # definitions name. A view the plan reads through is no start: its relations are in the plan.
327
+ def views_reached(text)
328
+ down = self.class.fold(text)
329
+ reached = @views.to_h.keys.select { |view| !@traced.include?(view) && names?(down, view) }.to_h { |view| [ view, view ] }
330
+ waiting = reached.keys
331
+ while (view = waiting.shift)
332
+ definition = self.class.fold(@views[view])
333
+ (@views.keys - reached.keys).each do |other|
334
+ next unless names?(definition, other)
335
+
336
+ reached[other] = reached[view]
337
+ waiting << other
338
+ end
339
+ end
340
+ reached
341
+ end
342
+
343
+ def too_deep(found, followed, family)
344
+ found = found.map { |schema| self.class.fold(schema) }
345
+ view = followed.find { |name, _start| (dotted_names(self.class.fold(@views[name])) & found).any? }&.last
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)
353
+ end
354
+
355
+ def learn(relations:, views: nil, traced_views: [], generated: {}, index_tables: {})
356
+ fold = self.class.method(:fold)
357
+ [ relations, views, index_tables ].each { |named| named&.each_key { |name| @spelled[fold.call(name)] ||= name.to_s } }
358
+ relations.each { |relation, names| @columns[fold.call(relation)].concat(Array(names).map(&fold)) }
359
+ generated.each do |relation, expressions|
360
+ @columns[fold.call(relation)].concat(expressions.keys.map(&fold))
361
+ (@expressions[relation] ||= {}).merge!(expressions) { |_column, known, read| "#{known} #{read}" }
362
+ end
363
+ views&.each do |view, definition|
364
+ view = fold.call(view)
365
+ @views[view] = @views.key?(view) ? [ @views[view], definition ].then { |both| both.all? ? both.join("\n") : nil } : definition
366
+ end
367
+ @traced.concat(traced_views.map(&fold))
368
+ @index_tables.merge!(index_tables)
369
+
370
+ @generated = {}
371
+ flag_secret_columns(@columns)
372
+ # A generated column can read another one, so this repeats until it finds no more.
373
+ flag_secret_columns(@columns) while trace_generated(@expressions)
374
+ @unread_tables = unread_tables(@index_tables)
375
+ # A virtual table can be built on a refused view, and a view on that virtual table.
376
+ loop do
377
+ @refused_views = @views ? refused_views(@views).except(*@traced) : {}
378
+ built = built_on_unread(@index_tables)
379
+ break if built.empty?
380
+
381
+ @unread_tables.merge!(built)
382
+ end
383
+ end
384
+
385
+ def flag_secret_columns(columns)
386
+ @by_relation = columns.each_with_object({}) do |(relation, names), acc|
387
+ flagged = names.uniq.select { |c| secret?(c) }
388
+ acc[relation] = flagged unless flagged.empty?
389
+ end
390
+ @in_schema = @by_relation.values.flatten.uniq.freeze
391
+ @named = ((@redacted.to_a - @allowed.to_a) | @encrypted.to_a | @in_schema).map { |c| [ c, self.class.identifier(c) ] }
392
+ end
393
+
394
+ # Whether another secret column was found that a generated column reads. Every source of a name is kept.
395
+ def trace_generated(generated)
396
+ found = false
397
+ generated.each_value do |expressions|
398
+ expressions.each do |column, expression|
399
+ column = self.class.fold(column)
400
+ down = self.class.fold(expression)
401
+ sources = @named.filter_map { |name, pattern| name if name != column && down.match?(pattern) } - @generated.fetch(column, [])
402
+ next if sources.empty?
403
+
404
+ (@generated[column] ||= []).concat(sources)
405
+ found = true
406
+ end
407
+ end
408
+ found
409
+ end
410
+
411
+ # view name => why it is refused, followed through views built on refused views.
412
+ def refused_views(views)
413
+ views = views.to_h { |view, definition| [ self.class.fold(view), self.class.fold(definition) ] }
414
+ refused = views.transform_values { |definition| refusal_of(definition) }.compact
415
+ loop do
416
+ nested = views.filter_map do |view, definition|
417
+ inner = refused.keys.find { |other| names?(definition, other) } unless refused.key?(view)
418
+ [ view, inner ] if inner
419
+ end
420
+ return refused if nested.empty?
421
+
422
+ nested.each { |view, inner| refused[view] = "it reads the view `#{spelled(inner)}`: #{refused[inner]}" }
423
+ end
424
+ end
425
+
426
+ def unread_tables(index_tables)
427
+ index_tables.each_with_object({}) do |(table, (kind, owner)), unread|
428
+ secret = of_relation(owner).first
429
+ why = case kind
430
+ when :pages then "it returns the database file's raw pages, and every stored value is in them"
431
+ when :shadow
432
+ "it is a shadow table, where SQLite keeps what the virtual table `#{owner}` stores under other column names" \
433
+ "#{". Query `#{owner}`" unless secret}"
434
+ when :vocabulary
435
+ "it lists the words of the full-text table `#{owner}`, which holds the secret column `#{secret}`" if secret
436
+ when :virtual
437
+ if secret
438
+ "it is a virtual table that holds the secret column `#{secret}`, and a search or a function over a virtual " \
439
+ "table reads a column by its position, with no name written"
440
+ end
441
+ end
442
+ unread[self.class.fold(table)] = why if why
443
+ end
444
+ end
445
+
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}|\([^()]*\)|[^,])+/
451
+
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.
454
+ def built_on_unread(index_tables)
455
+ index_tables.each_with_object({}) do |(table, (kind, owner, definition)), built|
456
+ table = self.class.fold(table)
457
+ next if definition.nil? || @unread_tables.key?(table)
458
+
459
+ down = self.class.fold("#{owner if kind == :vocabulary} #{relation_words(table, definition)}")
460
+ view, why = @refused_views.find { |name, _why| name != table && names?(down, name) }
461
+ other = @unread_tables.keys.find { |name| name != table && names?(down, name) } unless view
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))
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(" ")
484
+ end
485
+
486
+ def refusal_of(definition)
487
+ if definition.strip.empty?
488
+ "its definition could not be read, and this database holds secret columns" if @in_schema.any?
489
+ elsif (column = named_in(definition))
490
+ "its definition names the secret column `#{column}`"
491
+ elsif (table = @unread_tables.keys.find { |name| names?(definition, name) })
492
+ "its definition names `#{spelled(table)}`, which is not read"
493
+ elsif definition.match?(WILDCARD) && (relation = @by_relation.keys.find { |name| names?(definition, name) })
494
+ "its definition has a wildcard and names `#{spelled(relation)}`, which holds the secret column `#{@by_relation[relation].first}`"
495
+ end
496
+ end
497
+
498
+ def names?(down, name)
499
+ down.match?(self.class.identifier(name))
500
+ end
501
+
502
+ def heuristic?(down)
503
+ down.end_with?(*HEURISTIC_SUFFIXES) || down.match?(/password|secret|token|nonce/) || down == "iv" || down.end_with?("_iv")
504
+ end
505
+ end
506
+ end
@@ -90,11 +90,11 @@ module RailsAiContext
90
90
 
91
91
  def database_heading_lines(name, tables, secondary)
92
92
  relations = Introspectors::SchemaConventions.relations_phrase(tables)
93
- return [ "## primary (#{SchemaAdapter.label(context)}, #{relations})", "" ] unless name
93
+ return [ "## #{Payload.primary_label(context)} (#{SchemaAdapter.label(context)}, #{relations})", "" ] unless name
94
94
 
95
95
  db = secondary[name]
96
96
  adapter = SchemaAdapter.secondary_label(context, name, db)
97
- lines = [ "## #{name} (#{[ adapter, relations ].compact.join(', ')})", "" ]
97
+ lines = [ "## #{name} (#{[ Payload.named_scope_words(context, name), adapter, relations ].compact.join(', ')})", "" ]
98
98
  # Where the snapshot comes from: a booted run reads the primary live, this from its files.
99
99
  lines.push("_#{db[:note]}._", "") if db[:note]
100
100
  lines
@@ -75,16 +75,18 @@ module RailsAiContext
75
75
  schema = Payload.section(context, :schema)
76
76
  secondary = Payload.app_databases(schema)
77
77
 
78
- sections = [ database_schema_text(schema[:tables], secondary.any? ? "primary" : nil) ]
79
- secondary.each { |name, db| sections << database_schema_text(db[:tables], name, db[:note]) }
80
- frameworks = Payload.framework_databases(schema).map { |name, db| SectionFacts.framework_database_text(name, db) }
78
+ sections = [ database_schema_text(schema[:tables], secondary.any? ? Payload.primary_label(context) : nil) ]
79
+ secondary.each { |name, db| sections << database_schema_text(db[:tables], name, db[:note], Payload.named_scope_words(context, name)) }
80
+ frameworks = Payload.framework_databases(schema).map do |name, db|
81
+ SectionFacts.framework_database_text("#{name}#{Payload.named_database_mark(context, name)}", db)
82
+ end
81
83
  sections << "Framework databases, their tables not listed: #{frameworks.join('; ')}" if frameworks.any?
82
84
  sections.join("\n\n")
83
85
  end
84
86
 
85
- def database_schema_text(tables, database = nil, note = nil)
87
+ def database_schema_text(tables, database = nil, note = nil, scope = nil)
86
88
  phrase = Introspectors::SchemaConventions.relations_phrase(tables)
87
- lines = [ database ? "## Database Schema: #{database} (#{phrase})" : "## Database Schema (#{phrase})" ]
89
+ lines = [ database ? "## Database Schema: #{database} (#{[ scope, phrase ].compact.join(', ')})" : "## Database Schema (#{phrase})" ]
88
90
  lines << "_#{note}._" if note
89
91
  tables&.each do |name, data|
90
92
  lines << "### #{escape_markdown(name)}"
@@ -490,16 +492,18 @@ module RailsAiContext
490
492
 
491
493
  # The other databases' own migrations, which the primary's counts leave out.
492
494
  secondary = data[:secondary_databases].is_a?(Hash) ? data[:secondary_databases] : {}
493
- primary = " (primary)" if secondary.any?
495
+ primary = " (#{Payload.primary_label(context)})" if secondary.any?
494
496
 
495
497
  lines = [ "## Migrations" ]
496
498
  lines << "- Total: #{data[:total]}#{primary}"
497
499
  lines << "- Schema version: #{data[:schema_version]}#{primary}" if data[:schema_version]
498
500
  secondary.each do |name, db|
499
- lines << "- #{name}: #{SectionFacts.migration_counts(db[:total], db[:pending])} in #{Array(db[:migrations_paths]).join(', ')}"
501
+ paths = Array(db[:migrations_paths])
502
+ counts = SectionFacts.migration_counts(db[:total], db[:pending])
503
+ lines << "- #{name}#{Payload.named_database_mark(context, name)}: #{counts}#{" in #{paths.join(', ')}" if paths.any?}"
500
504
  end
501
505
 
502
- pending = { "primary" => Payload.pending_migrations(context) }.merge(secondary.transform_values { |db| Array(db[:pending]) })
506
+ pending = { Payload.primary_label(context) => Payload.pending_migrations(context) }.merge(secondary.transform_values { |db| Array(db[:pending]) })
503
507
  pending.each do |name, migrations|
504
508
  next if migrations.empty?
505
509
 
@@ -508,7 +512,7 @@ module RailsAiContext
508
512
  end
509
513
 
510
514
  if data[:recent]&.any?
511
- lines << "### Recent Migrations#{": primary" if secondary.any?}"
515
+ lines << "### Recent Migrations#{": #{Payload.primary_label(context)}" if secondary.any?}"
512
516
  data[:recent].each do |m|
513
517
  actions = m[:actions]&.any? ? " (#{m[:actions].join(', ')})" : ""
514
518
  lines << "- `#{m[:version]}` #{m[:name]}#{actions}"
@@ -623,7 +627,7 @@ module RailsAiContext
623
627
  if data[:databases]&.any?
624
628
  data[:databases].each do |db|
625
629
  replica = db[:replica] ? " (replica)" : ""
626
- lines << "- `#{db[:name]}` - #{SchemaAdapter.database_label(db) || "unknown"}#{replica}"
630
+ lines << "- `#{db[:name]}` - #{SchemaAdapter.database_label(db) || "unknown"}#{replica}#{Payload.database_mark(db)}"
627
631
  end
628
632
  end
629
633
 
@@ -41,13 +41,23 @@ module RailsAiContext
41
41
 
42
42
  parts = [ "#{SchemaAdapter.label(ctx)} - #{CountPhrase.call(schema[:total_tables].to_i, "table")}" ]
43
43
  Payload.secondary_databases(schema).each do |name, db|
44
+ label = "#{name}#{Payload.named_database_mark(ctx, name)}"
45
+ next parts << "#{label}: #{empty_database_text(db)}" if db[:tables].empty?
46
+
44
47
  tables = CountPhrase.call(db[:total_tables].to_i, "table")
45
48
  adapter = SchemaAdapter.secondary_label(ctx, name, db)
46
- parts << (framework_database_text(name, db) || "#{name}: #{adapter ? "#{adapter} - #{tables}" : tables}")
49
+ parts << (framework_database_text(label, db) || "#{label}: #{adapter ? "#{adapter} - #{tables}" : tables}")
47
50
  end
48
51
  "- Database: #{parts.join('; ')}"
49
52
  end
50
53
 
54
+ # A dump that was read and holds no table says so, in the schema section's words.
55
+ def empty_database_text(db)
56
+ return "tables not read" if db[:unread]
57
+
58
+ db[:dump] ? "#{db[:dump]} #{Introspectors::SchemaIntrospector::NO_TABLES}" : "no tables"
59
+ end
60
+
51
61
  # A database a Rails framework keeps to itself, as every file names it:
52
62
  # "queue: Solid Queue (13 tables)". Its tables are the framework's, so
53
63
  # no file lists them. nil for a database of the app's own.
@@ -65,7 +75,7 @@ module RailsAiContext
65
75
  pending = Payload.pending_migrations(ctx) if migrations.key?(:pending)
66
76
  parts = [ migration_counts(migrations[:total], pending) ]
67
77
  secondary = migrations[:secondary_databases].is_a?(Hash) ? migrations[:secondary_databases] : {}
68
- secondary.each { |name, db| parts << "#{name}: #{migration_counts(db[:total], db[:pending])}" }
78
+ secondary.each { |name, db| parts << "#{name}#{Payload.named_database_mark(ctx, name)}: #{migration_counts(db[:total], db[:pending])}" }
69
79
  "- Migrations: #{parts.join('; ')}"
70
80
  end
71
81
 
@@ -83,10 +83,13 @@ module RailsAiContext
83
83
  raw_databases = Payload.section(ctx, :multi_database)&.dig(:databases)
84
84
  db_list = raw_databases.is_a?(Hash) ? raw_databases.keys : Array(raw_databases)
85
85
  if db_list.size > 1
86
- db_names = db_list.map { |d| d.is_a?(Hash) ? d[:name] : d }
86
+ db_names = db_list.map { |d| d.is_a?(Hash) ? "#{d[:name]}#{Payload.database_mark(d)}" : d }
87
87
  # The names it leaves out are counted, as the mounted apps' are.
88
88
  shown = db_names.first(DATABASES_SHOWN)
89
- shown << "...#{db_names.size - DATABASES_SHOWN} more" if db_names.size > DATABASES_SHOWN
89
+ if db_names.size > DATABASES_SHOWN
90
+ elsewhere = db_list.drop(DATABASES_SHOWN).count { |d| Payload.scope_words(d) }
91
+ shown << "...#{db_names.size - DATABASES_SHOWN} more#{", #{elsewhere} of them not configured in this environment" if elsewhere > 0}"
92
+ end
90
93
  lines << "- Databases: #{db_list.size} (#{shown.join(', ')})"
91
94
  end
92
95