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
data/docs/TOOLS.md CHANGED
@@ -145,9 +145,12 @@ tables of every database the app's own schema dumps declare, as
145
145
  Cache and Solid Cable get one line, without their tables. A database whose
146
146
  dump was not read is named with "(tables not read)" and left out of the table
147
147
  count, and the summary says so: "5 tables in 2 databases, the warehouse
148
- database not read". It ends with the app's custom
149
- rake tasks (Rakefile, `lib/tasks`, `rakelib`), each with its arguments,
150
- description and file: the first 15 at `standard`, every one at `full`.
148
+ database not read". It ends with the app's custom rake tasks (Rakefile,
149
+ `lib/tasks`, `rakelib`), each with its arguments, description and file: the
150
+ first 15 at `standard`, every one at `full`. A known limit: on
151
+ `preset: standard` on the static tier the stack line reads the primary's
152
+ adapter as `unknown`, and a database's heading in the generated files can name
153
+ no adapter.
151
154
  Then the app's own generators under `lib/generators` (the `bin/rails generate`
152
155
  command and its USAGE line), the `lib/templates` files that replace a built-in
153
156
  generator's template, and the Railties under `lib/` with their initializers.
@@ -178,6 +181,14 @@ its own path, else from the other format's file under its name
178
181
  (`db/queue_schema.rb` or `db/queue_structure.sql`), whatever `database_tasks`
179
182
  or `schema_format` says.
180
183
 
184
+ A database only another environment configures carries a mark, as in
185
+ `cache (production only)`, and a first database that is not named `primary`
186
+ is listed under its own name where another database is the one named
187
+ `primary`. Both are the same under every `preset`. With the
188
+ `multi_database` introspector off, as on `preset: standard`, `rails_get_schema`,
189
+ `rails_onboard`, `rails_performance_check` and the generated files read them
190
+ from the app databases list.
191
+
181
192
  Column hints follow the leftmost-prefix rule: `[indexed]` means some index leads
182
193
  with the column, so a lookup on it alone can use one; `[unique]` means a unique
183
194
  index covers the column alone; `[unique with x]` names the partners of a
@@ -471,7 +482,8 @@ database's name is read whatever `database_tasks` or `schema_dump` says. A
471
482
  database whose tables could not be read is listed under **Not checked** with
472
483
  the reason: its dump is not on disk or is over `max_schema_file_size`, or its
473
484
  `config/database.yml` entry turns `database_tasks` or `schema_dump` off and no
474
- dump is on disk. A database another environment configures carries its mark:
485
+ dump is on disk, or "`schema_dump` in its database.yml entry names no file this
486
+ check reads". A database another environment configures carries its mark:
475
487
  `**Not checked:** warehouse (production only, db/warehouse_schema.rb is not on disk)`.
476
488
 
477
489
  | Parameter | Type | Default | Description |
@@ -735,13 +747,13 @@ Safe read-only SQL with layered security: the SQL gate, a PostgreSQL plan check
735
747
 
736
748
  It runs on PostgreSQL, MySQL (with MariaDB) and SQLite, each with its own read-only transaction and statement timeout. On any other adapter it answers with that adapter's name and runs nothing. A statement that runs past `query_timeout` is answered as a timeout and any other database error as an SQL error. Both are error responses: `isError` over MCP and a non-zero exit on the CLI. So is a database no connection reaches, which the answer names with what to do about it. So are a locked SQLite database and the refusal in production.
737
749
 
738
- On PostgreSQL, MySQL and MariaDB every statement a call sends has a time limit. The query has `query_timeout` to itself and the reads beside it share a second one, so the statements of a call take about twice `query_timeout` at most. On SQLite the query has `query_timeout`, and the reads beside it run on the app's own connection with no statement limit. The [security model](SECURITY.md#layer-2---database-level-read-only) says what bounds each statement and lists the stated limits.
750
+ On PostgreSQL, MySQL and MariaDB every statement a call sends has a time limit. The query has `query_timeout` to itself and the reads beside it share a second one, so the statements of a call take about twice `query_timeout` at most. On SQLite the query has `query_timeout`, and the reads beside it run on the app's own connection with no statement limit. A SQLite statement that names something the app's connection alone holds, such as a temp table or an attached database, runs on that connection, and its answer says it ran without a time limit. The [security model](SECURITY.md#layer-2---database-level-read-only) says what bounds each statement and lists the stated limits.
739
751
 
740
752
  On MySQL, MariaDB and SQLite a statement that names a view whose definition names a secret column is refused. So is a `*` inside a subquery when the statement names a table that holds a secret column, and on SQLite `IN` before a table's name (`x IN t`). On PostgreSQL a materialized view whose definition names a secret column is refused. On SQLite a virtual table that holds a secret column, a shadow table and the page modules are refused, and only the pragma functions that describe the schema or the database file run.
741
753
 
742
754
  The secret column rule reads the columns of every schema and database a statement reaches: the ones its text names, on PostgreSQL the ones its plan names, and the ones the views it names lead to, three reads deep. So a secret column of a table in another one is refused by name too.
743
755
 
744
- On MySQL and MariaDB the text checks read a backslash in a string both ways, and a statement that either reading refuses is refused. On every family the text checks give up with a refusal when they have run longer than `query_timeout`.
756
+ On MySQL and MariaDB the text checks read a backslash in a string both ways, and a statement that either reading refuses is refused. There a `[` outside a string or a quoted name, a user variable assignment (`:=`) and `LAST_INSERT_ID` with an argument are refused too. On every family the text checks give up with a refusal when they have run longer than `query_timeout`.
745
757
 
746
758
  | Parameter | Type | Default | Description |
747
759
  |:----------|:-----|:--------|:------------|
@@ -755,7 +767,7 @@ On MySQL and MariaDB the text checks read a backslash in a string both ways, and
755
767
 
756
768
  - A name the app does not have is answered with the closest one and every name it has.
757
769
  - A database only another environment configures, a replica and one with `database_tasks` off included, is answered with the environments that do.
758
- - A top-level entry of `config/database.yml` that code connects to by name (`establish_connection(:reporting)`) is an environment to Rails, so `database` does not take its name: the answer lists the databases of the running environment.
770
+ - A top-level entry of `config/database.yml` that code connects to by name (`establish_connection(:reporting)`) is an environment to Rails, so `database` does not take its name: the answer lists every database the app has, as in "No database named reporting. This app has: primary, cache, queue".
759
771
  - One that only a committed dump names is answered as such.
760
772
  - On the static tier the whole tool is unavailable, with or without `database`.
761
773
  - A SQLite database whose file is not there is an error, with or without `database`. The message starts "The <name> database does not exist yet" and names `bin/rails db:prepare`, and nothing is created. A `file:` URI is checked at the path inside it.
@@ -818,7 +830,7 @@ Each diff passes through the redaction every tool's source does, read as the fil
818
830
 
819
831
  Live database pool stats, table sizes, pending migrations, cache stats, queue depth.
820
832
 
821
- Table sizes come from PostgreSQL, MySQL (base tables only) and SQLite, and index usage from PostgreSQL. On MySQL, MariaDB and SQLite the full detail says "_Index usage is not available for the MySQL database family._", or SQLite. On an adapter with no database family the database section says table sizes and index usage are not available for that adapter.
833
+ Table sizes come from PostgreSQL, MySQL (base tables only) and SQLite, and index usage from PostgreSQL. On MySQL, MariaDB and SQLite the full detail says "_Index usage is not available for the MySQL database family._", or SQLite. On an adapter with no database family the database section says table sizes and index usage are not available for that adapter. A read that failed or took longer than `query_timeout` is said in one line: "_Table sizes were not read: the read failed or took longer than query_timeout._", and at `detail: "full"` "_Index usage was not read: the read failed or took longer than query_timeout._"
822
834
 
823
835
  The pool and the cache are read inside the MCP server process, and the answer says so: the pool's counts are that process's own connections, and a `MemoryStore`'s entries are its own, since each app server process holds its own. A store the processes share (Redis, Memcached, Solid Cache, a file store) is the app's. Another environment that configures a shared store is named, as where the app's cache lives there. Solid Cache's stats are read from its database, so when that is a SQLite file that is not there, the cache section says the database does not exist yet and creates nothing.
824
836
 
@@ -278,6 +278,7 @@ The SQL gate refuses write statements, file and admin functions and secret colum
278
278
 
279
279
  What a string literal holds decides some of these:
280
280
  - A write keyword or a semicolon inside a string (`WHERE note = 'please insert coin'`, `'a; b'`) is data on every database, and the query runs
281
+ - A write after a `WITH` clause (`WITH ... INSERT INTO ...`) is named as that write: "Blocked: contains INSERT"
281
282
  - A secret column's name, `FOR UPDATE` / `FOR SHARE` or `UNION` inside a string is data on PostgreSQL only. On MySQL, MariaDB and SQLite the raw text decides and the query is refused, since no later check there would catch a quote the masking misread
282
283
  - `INTO OUTFILE` / `INTO DUMPFILE` is refused wherever it stands, inside a string too, on every database
283
284
  - A file or network function (`pg_read_file`, `LOAD_FILE`), a refused `SHOW` form (`SHOW GRANTS`) and a relation that is not read (`pg_stats`, `slow_log`) are refused wherever they stand, inside a string too, each on the database that has it. `slow_log` is refused on MySQL and MariaDB, and `pg_stats` on PostgreSQL
@@ -292,7 +293,7 @@ MySQL, MariaDB and SQLite trace no result column back to its table column, so th
292
293
  - A `*` inside a subquery, a CTE body included, when the statement names a table that holds a secret column. The message names that table and the column. The rule tests the two facts together and does not tell which table the wildcard reads, so the statement is refused also when the wildcard is over another table. Name the columns in the subquery: `WITH x AS (SELECT id, email FROM users) SELECT * FROM x`. On MySQL and MariaDB `(TABLE t)` counts as a wildcard: select the columns you need. `EXISTS (SELECT * ...)` and `count(*)` run
293
294
  - On SQLite, `IN` before a table's name (`WHERE id IN users`), when the statement names a table that holds a secret column. It is SQLite's shorthand for `IN (SELECT * FROM users)`. Write the subquery with its columns named: `WHERE id IN (SELECT id FROM users)`
294
295
  - On SQLite, either of those two in a statement that holds a string equal to such a table's name (`WHERE role = 'users'`). SQLite reads a string as a table's name where a name can stand, and the rule cannot tell that use from plain data. The message says "in a statement that holds the string 'users'". Name the columns in the subquery, or compare with another value
295
- - A view whose definition names a secret column, or has a wildcard and names a table that holds one. Query the tables the view reads. When the column holds no secret, list its name in `config.query_allowed_columns`
296
+ - A view whose definition names a secret column, or has a wildcard and names a table that holds one. Query the tables the view reads. When the column holds no secret, exempt it by name as [Column values show [FILTERED]](#column-values-show-filtered) says
296
297
 
297
298
  On PostgreSQL a plain view runs and its secret columns come back `[FILTERED]`. A materialized view whose definition names a secret column gets the view message, since its stored rows cannot be traced to a table column. Query the tables it was built from.
298
299
 
@@ -306,11 +307,11 @@ On SQLite `rails_query` reads no table that holds another table's rows or words
306
307
  - An `fts5vocab` or `fts4aux` table over such a table lists its words, so it is refused too
307
308
  - A virtual table whose definition names a refused view, or a table that is not read, holds their rows or words. The message says "its definition names" and what it names
308
309
 
309
- A view over one of these is refused with "its definition reads `<table>`, which is not read". The name is refused inside a string too.
310
+ A view over one of these is refused with "its definition names `<table>`, which is not read". The name is refused inside a string too.
310
311
 
311
312
  ### "Blocked: `pragma_foreign_keys` tells of the connection that runs it"
312
313
 
313
- On SQLite `rails_query` runs a statement on a connection of its own, so a value that belongs to a connection would not be the app's. The pragma functions that describe the schema or the database file run: `pragma_table_info`, `pragma_index_list`, `pragma_foreign_key_list`, `pragma_integrity_check`, `pragma_page_count` and the others [SECURITY.md](SECURITY.md#layer-1---the-sql-gate-statement-checks) lists. Every other `pragma_*` function is refused, and so are `changes()`, `total_changes()` and `last_insert_rowid()`. Read those on the app's own connection, in `bin/rails runner` or the console.
314
+ On SQLite `rails_query` runs a statement on a connection of its own, so a value that belongs to a connection would not be the app's. The pragma functions that describe the schema or the database file run: `pragma_table_info`, `pragma_index_list`, `pragma_foreign_key_list`, `pragma_integrity_check`, `pragma_page_count` and the others [SECURITY.md](SECURITY.md#layer-1---the-sql-gate-statement-checks) lists. Every other `pragma_*` function is refused, and so are `changes()`, `total_changes()` and `last_insert_rowid()`. Read those on the app's own connection, in `bin/rails runner` or the console. A `pragma_*` name is refused wherever it stands, so a column whose own name starts with `pragma_` is refused too: read it in the console as well.
314
315
 
315
316
  ### "Blocked: the statement is ... bytes long"
316
317
 
@@ -318,7 +319,7 @@ On SQLite `rails_query` runs a statement on a connection of its own, so a value
318
319
 
319
320
  ### "Blocked: the checks on the statement's text ran longer than query_timeout"
320
321
 
321
- The checks that read a statement's text have `query_timeout` to themselves and give up when they pass it. Nothing was sent to the database. Send a shorter statement.
322
+ The checks that read a statement's text have `query_timeout` to themselves and give up when they pass it. The statement was not sent to the database. The schema read that comes before the checks was. Send a shorter statement.
322
323
 
323
324
  ### "Blocked: ... is not queried: its columns could not be read" and "leads through more databases than rails_query follows"
324
325
 
@@ -334,12 +335,32 @@ MySQL's statement timeout does not hold a `SHOW` filter, so it is refused on eve
334
335
 
335
336
  ### "A name before a dot is read as that schema, so an alias named `sys` needs another name"
336
337
 
337
- On MySQL and MariaDB `mysql.` and `sys.` are read as those schemas wherever they stand outside a single-quoted string. An alias cannot be told from a schema, so `FROM users sys ... sys.id` is refused. Give the alias another name. A double-quoted string that holds `mysql.` or `sys.` is refused too, with a sentence of its own: MySQL reads double-quoted text as a name when the session's sql_mode holds `ANSI_QUOTES`. Write it in single quotes.
338
+ On MySQL and MariaDB `mysql.` and `sys.` are read as those schemas wherever they stand outside a single-quoted string. An alias cannot be told from a schema, so `FROM users sys ... sys.id` is refused. Give the alias another name. The sentence ends every refusal over `mysql.` or `sys.` outside double-quoted text, so a real `FROM mysql.user` gets it too, with `mysql` as the name. A double-quoted string that holds `mysql.` or `sys.` is refused too, with a sentence of its own: MySQL reads double-quoted text as a name when the session's sql_mode holds `ANSI_QUOTES`. Write it in single quotes.
338
339
 
339
340
  ### "That is how MySQL reads the statement when the session's sql_mode holds NO_BACKSLASH_ESCAPES"
340
341
 
341
342
  On MySQL and MariaDB a backslash in a string is an escape, unless the session's sql_mode holds `NO_BACKSLASH_ESCAPES`. `rails_query` does not read the session's mode, so it checks the statement under both readings and refuses it when either one does. This message ends a refusal that the second reading alone gives: with `\'` read as a backslash and the end of the string, the text after it is code. Write a quote inside a string doubled (`'it''s'`), so that both readings agree.
342
343
 
344
+ ### "Blocked: a `[` outside a string or a quoted name is not run"
345
+
346
+ On MySQL and MariaDB a square bracket outside a string or a quoted name is refused. MariaDB reads `[name]` as a quoted name when the session's sql_mode holds `MSSQL`, and `rails_query` does not read the session's mode, so its checks could read such a statement another way than the server does. Quote a name in backticks: `` `name` ``. A `[` inside a string, such as a JSON path (`'$[0]'`), runs. One case apart: a string that holds a backslash before a quote (`'O\'Brien'`) is read a second way that ends it at that quote, and a `[` which that reading leaves outside a string is refused. Write the quote doubled (`'O''Brien'`).
347
+
348
+ ### "Blocked: a user variable assignment (`:=`) is not run"
349
+
350
+ On MySQL and MariaDB `SELECT @n := @n + 1` leaves `@n` set on the app's pooled connection after the query returns, and a read-only transaction does not stop that. To number rows, use a window function, which MySQL 8 and MariaDB 10.2 and later have: `SELECT ROW_NUMBER() OVER (ORDER BY id) AS n, title FROM posts`. A `:=` inside a string or a quoted name runs, unless a backslash before a quote in the statement leaves it outside a string in the second reading of a backslash: write that quote doubled.
351
+
352
+ ### "Blocked: LAST_INSERT_ID with an argument is not run"
353
+
354
+ On MySQL and MariaDB `LAST_INSERT_ID(<value>)` sets the value the next `LAST_INSERT_ID()` returns, and it stays on the app's pooled connection after the query returns. `LAST_INSERT_ID()` with no argument runs. The call is refused wherever it stands, inside a string too.
355
+
356
+ ### "Blocked: a column-alias list" on a statement that has none
357
+
358
+ One name in parentheses after `GLOB`, `MATCH` or `OVER` that follows a subquery has the shape of a column-alias list, so `WHERE (SELECT title FROM post_titles LIMIT 1) GLOB (title)` and `sum((SELECT 1)) OVER (w)` are refused. Write the name with no parentheses (`GLOB title`), or write the window inline (`OVER (ORDER BY id)`).
359
+
360
+ ### "Blocked: UNION is not run" on a statement with no UNION
361
+
362
+ A quoted name that spells the word is refused, as in `SELECT 1 AS "union"`. Give the alias another name. On PostgreSQL the word inside a string is data.
363
+
343
364
  ### "Blocked: the setting ... is not read", "Blocked: SHOW ALL is not run" and "Blocked: SHOW takes a setting's plain name on PostgreSQL"
344
365
 
345
366
  On PostgreSQL `rails_query` reads one setting at a time, by its plain name: `SHOW work_mem`. `SHOW ALL` and a quoted name (`SHOW "work_mem"`) are refused. Six settings can hold a password or a command line that carries one and are refused: `primary_conninfo`, `ssl_passphrase_command`, `archive_command`, `restore_command`, `archive_cleanup_command` and `recovery_end_command`. So is a setting whose own name reads as a secret, such as `app.jwt_secret`.
@@ -362,7 +383,11 @@ The secret column rule reads the database's columns before any query runs. When
362
383
 
363
384
  ### "The database is locked by another connection, so the query did not run"
364
385
 
365
- Another connection holds a lock on the SQLite database, such as a write transaction left open in a console. Nothing ran. End that transaction and run the query again. The query waits on a lock for 80% of `query_timeout` before it answers this. A lock met while the connection opens, as on the CLI or on a server's first call, gets the same answer.
386
+ Another connection holds a lock on the SQLite database, such as a write transaction left open in a console. Nothing ran. End that transaction and run the query again. The query waits on a lock for 80% of `query_timeout` before it answers this. A lock met while the connection opens, as on the CLI or on a server's first call, gets the same answer. A query that holds its read lock and then runs past `query_timeout` answers the timeout, also when a writer asks for the lock while it runs.
387
+
388
+ ### "This SQLite query ran without a time limit"
389
+
390
+ On SQLite `rails_query` runs a statement in a process of its own, which it ends at `query_timeout`. That process cannot see what the app's own connection alone holds. So the statement runs on the app's connection, with this note, on an in-memory database, on a platform without `fork`, inside an open transaction, and when it needs a table, a function, a module, a collation or an encryption key only that connection has. While the connection holds a temp object or an attachment, it runs there too when it names a temp object, the `temp` schema, `sqlite_temp_master` or an attached database, or calls a pragma function (`pragma_table_info(...)`, `pragma_table_list` and the rest) or `dbstat`. A name is matched anywhere in the statement, in any letter case and inside a string too. A statement that returns rows slowly still ends a few rows after `query_timeout`. One long step cannot be ended there.
366
391
 
367
392
  ### "The reads rails_query makes before a statement ... took the whole <N> second timeout"
368
393
 
@@ -376,6 +401,8 @@ Columns whose names look secret are redacted, and a query that names one is reje
376
401
  config.query_allowed_columns = %w[secret]
377
402
  ```
378
403
 
404
+ On a standalone install, where no initializer runs, put the name under `query_allowed_columns:` in `.rails-ai-context.yml`.
405
+
379
406
  ---
380
407
 
381
408
  ## Search issues
@@ -2,7 +2,7 @@
2
2
 
3
3
  Status: accepted
4
4
 
5
- `rails_query` promises two guards the database itself enforces: a read-only transaction and a statement timeout. Each database server spells both differently, and some cannot give one at all, so the tool runs a statement only where it knows the spelling. A **database family** is that knowledge: one per kind of database, read from the adapter's name in the database's configuration before any connection opens, and the only place a database's read-only and timeout rules live.
5
+ `rails_query` promises two guards the database itself enforces: a read-only transaction and a statement timeout. Each database server spells both differently, and some cannot give one at all, so the tool runs a statement only where it knows the spelling. A **database family** is that knowledge: one per kind of database, told by the adapter's name, and the only place a database's read-only and timeout rules live. `rails_query` reads the name from the database's configuration, before any connection opens.
6
6
 
7
7
  ## Decision
8
8
 
@@ -12,7 +12,7 @@ Status: accepted
12
12
  - PostgreSQL: `SET LOCAL statement_timeout` inside the read-only transaction, at one millisecond or more, since zero turns the timeout off.
13
13
  - MySQL: the session's `max_execution_time`, read first, set for the statement and restored after. The `MAX_EXECUTION_TIME` hint binds only the outermost SELECT and leaves a statement that starts with WITH or EXPLAIN with no bound, so it is not used.
14
14
  - MariaDB: the session's `max_statement_time`, read first, set for the statement and restored after. MariaDB ignores the MySQL hint and has no `max_execution_time`.
15
- - SQLite: a forked child with a deadline, since sqlite3-ruby holds the interpreter lock while a statement steps and nothing in the process can end it. A statement the child cannot prepare runs on the app's own connection, where a watchdog's interrupt ends one that returns rows slowly.
15
+ - SQLite: a forked child with a deadline, since sqlite3-ruby holds the interpreter lock while a statement steps and nothing in the process can end it. A statement runs on the app's own connection in five cases: an in-memory database, a platform without `fork`, a statement the child cannot prepare, a transaction open on that connection, and, while that connection holds a temp object or an attachment, a statement that names one of them, the temp catalog or schema, or calls a pragma function or `dbstat`. There a watchdog's interrupt ends a statement that returns rows slowly.
16
16
  - What bounds each kind of statement a call sends:
17
17
  - The query and `explain: true` have `query_timeout` to themselves: `statement_timeout` on PostgreSQL, the session's statement limit with `lock_wait_timeout` on MySQL and MariaDB, the child's deadline on SQLite.
18
18
  - The reads beside the query (the schema, the PostgreSQL plan with its `pg_proc` lookup, the column trace) run in a block the family bounds the same way. They share a second `query_timeout`, and each is charged the time it ran. On SQLite they run on the app's own connection with no statement limit, and a wait on another writer ends at its busy timeout.
@@ -24,7 +24,7 @@ Status: accepted
24
24
  - MySQL's statement limit holds `SELECT` only, so the gate refuses `SHOW` with a `WHERE` clause on every family.
25
25
  - A timeout is recognised by `ActiveRecord::QueryAborted` (QueryCanceled, StatementTimeout) or by the database's native code. MariaDB's 1969 has no Rails class in any version. Message text is never read, so a missing column named "timeout" is reported as an SQL error.
26
26
  - The statement checks before execution stay on every family. MySQL's read-only transaction lets `FOR SHARE`, `LOCK IN SHARE MODE`, `INTO @var` and `INTO OUTFILE` through, so it cannot be the only guard.
27
- - Only PostgreSQL traces a result column to its table column, through the statement's plan, so there a statement runs only with a plan. MySQL, MariaDB and SQLite have no such trace. There a view is checked by the definition the database stores and refused when it names a secret column, and a `*` inside a subquery is refused when the statement names a table that holds one.
27
+ - Only PostgreSQL traces a result column to its table column, through the statement's plan, so there a statement runs only with a plan. MySQL, MariaDB and SQLite have no such trace. There a view is checked by the definition the database stores and refused when it names a secret column, and a `*` inside a subquery is refused when the statement names a table that holds one. A PostgreSQL materialized view stores its rows, so the plan traces none of its columns either, and it is held to the same view rule.
28
28
  - A guarded read leaves an open transaction as it found it: a savepoint on PostgreSQL, and a refusal on MySQL and MariaDB, which cannot make an open transaction read-only.
29
29
 
30
30
  ## Consequences
@@ -20,9 +20,8 @@ module RailsAiContext
20
20
  REPLACED = "the connection was replaced before the statement was sent, and the new session has no " \
21
21
  "read-only transaction and no time limit, so the statement was not sent"
22
22
 
23
- # The statement limit does not cover SHOW, so lock_wait_timeout (whole seconds) ends a wait on a
24
- # metadata lock. Both session values are read first and put back after the block.
25
- # Yields the id of the session that holds them.
23
+ # The statement limit does not cover SHOW, so lock_wait_timeout (whole seconds) ends a wait on a metadata lock.
24
+ # Both session values are put back after the block, which is given the id of the session that holds them.
26
25
  def bounded(conn, timeout)
27
26
  limit, value = if conn.mariadb? then [ "max_statement_time", [ timeout.to_f, 0.001 ].max ]
28
27
  else [ "max_execution_time", [ (timeout * 1000).to_i, 1 ].max ]
@@ -16,6 +16,8 @@ module RailsAiContext
16
16
  SYSTEM_SCHEMA = /\A(?:pg_catalog|information_schema|pg_toast(?:_temp_\d+)?)\z/
17
17
 
18
18
  APPENDS = [ "Append", "Merge Append" ].freeze
19
+ REMOTE_NAME = /"(?:""|[^"])+"|[^\s()."]+/
20
+ REMOTE_RELATION = /\((?:(#{REMOTE_NAME})\.)?(#{REMOTE_NAME})(?: (#{REMOTE_NAME}))?\)/
19
21
 
20
22
  # aliases: alias => the relations it stands for, each as `schema.relation` where the plan gives a schema that is no system one.
21
23
  attr_reader :nodes, :aliases
@@ -25,17 +27,19 @@ module RailsAiContext
25
27
  @top = top
26
28
  @nodes = []
27
29
  @parents = {}.compare_by_identity
30
+ @appended = {}.compare_by_identity
28
31
  collect(top)
29
32
  # The alias defaults to the relation name when the query gave none, which is how `users.*` resolves.
30
33
  # Keyed as EXPLAIN prints it: `t` and "T" are two aliases.
31
34
  @names = {}
32
35
  @aliases = @nodes.each_with_object({}) do |node, map|
33
- rel = node["Relation Name"] or next
34
- key = [ (node["Schema"] unless node["Schema"].to_s.match?(SYSTEM_SCHEMA)), rel ].compact.join(".").downcase
35
- @names[key] = rel.to_s.downcase
36
- as = (node["Alias"] || rel).to_s
37
- # The members of an Append over partitions are `name_1`, `name_2`, and a node over them names their columns by `name`.
38
- [ as, as[/\A(.+)_\d+\z/m, 1] ].compact.each { |name| (map[name] ||= []) << key }
36
+ scanned(node).each do |schema, rel, as|
37
+ key = [ (schema unless schema.to_s.match?(SYSTEM_SCHEMA)), rel ].compact.join(".").downcase
38
+ @names[key] = rel.downcase
39
+ # The members of an Append over partitions are `name_1`, `name_2`, and a node over them names their columns by `name`.
40
+ # Anywhere else `name_1` is the plan's own name for a second use of `name`, which stands for another relation.
41
+ [ as, (as[/\A(.+)_\d+\z/m, 1] if @appended[node]) ].compact.each { |name| (map[name] ||= []) << key }
42
+ end
39
43
  end
40
44
  @aliases.each_value(&:uniq!)
41
45
  end
@@ -48,15 +52,13 @@ module RailsAiContext
48
52
  # @return [Array<Array(String, String)>] [schema, `schema.relation`] for each relation the plan reads
49
53
  # outside the system schemas
50
54
  def relations
51
- @nodes.filter_map do |node|
52
- schema = node["Schema"]
53
- [ schema, "#{schema}.#{node["Relation Name"]}".downcase ] if schema && node["Relation Name"] && !schema.match?(SYSTEM_SCHEMA)
55
+ @nodes.flat_map { |node| scanned(node) }.filter_map do |schema, rel, _as|
56
+ [ schema, "#{schema}.#{rel}".downcase ] if schema && !schema.match?(SYSTEM_SCHEMA)
54
57
  end.uniq
55
58
  end
56
59
 
57
- # The expressions that can stand at each result column: entry i is result column i, with any sort or group key
58
- # past the result width. An Append prints no Output, and each of its members hands up a row of its own.
59
- # @return [Array<Array<String>>]
60
+ # @return [Array<Array<String>>] the expressions that can stand at result column i, with any sort or group key
61
+ # past the result width. An Append prints no Output, and each of its members hands up a row of its own.
60
62
  def output
61
63
  return Array(@top["Output"]).map { |expr| [ expr ] } if @top.key?("Output") || !APPENDS.include?(@top["Node Type"])
62
64
 
@@ -67,11 +69,11 @@ module RailsAiContext
67
69
  members.all? { |member| member["Relation Name"] } && columns.size == 1 ? outputs.transpose : []
68
70
  end
69
71
 
70
- # @return [Array<Array(String, String)>] [relation, column], downcased, for a plain Var of a base relation:
71
- # one for each relation its alias stands for
72
+ # @return [Array<Array(String, String)>] [relation, column] for a plain Var of a base relation, the column
73
+ # folded as the secret column rule folds a name: one for each relation its alias stands for
72
74
  def base_columns(expr)
73
75
  m = expr.to_s.strip.match(BASE_VAR) or return []
74
- column = (m[3] || m[4].gsub('""', '"')).downcase
76
+ column = SecretColumns.fold(m[3] || m[4].gsub('""', '"'))
75
77
  Array(aliases[m[1] || m[2].gsub('""', '"')]).map { |rel| [ rel, column ] }
76
78
  end
77
79
 
@@ -89,11 +91,24 @@ module RailsAiContext
89
91
 
90
92
  private
91
93
 
94
+ # [schema, relation, alias] of each relation the node scans. A join or an aggregate that a foreign server runs has
95
+ # no relation of its own: postgres_fdw names its tables there, each with its alias, as `(schema.table alias)`.
96
+ def scanned(node)
97
+ rel = node["Relation Name"]
98
+ return [ [ node["Schema"], rel.to_s, (node["Alias"] || rel).to_s ] ] if rel
99
+
100
+ node["Relations"].to_s.scan(REMOTE_RELATION).map do |parts|
101
+ schema, name, as = parts.map { |part| part&.delete_prefix('"')&.delete_suffix('"')&.gsub('""', '"') }
102
+ [ schema, name, as || name ]
103
+ end
104
+ end
105
+
92
106
  def collect(node, parent = nil)
93
107
  return unless node.is_a?(Hash)
94
108
 
95
109
  @nodes << node
96
110
  @parents[node] = parent
111
+ @appended[node] = true if parent && (@appended[parent] || APPENDS.include?(parent["Node Type"]))
97
112
  Array(node["Plans"]).each { |child| collect(child, node) }
98
113
  end
99
114
 
@@ -116,12 +116,12 @@ module RailsAiContext
116
116
  schemas.empty? ? "n.nspname = ANY (current_schemas(false))" : "#{SCHEMA} IN (#{schemas.map { |name| conn.quote(name) }.join(", ")})"
117
117
  end
118
118
 
119
- # Every relation that stores rows: tables, partitioned tables, materialized views and foreign tables.
119
+ # Every relation a plan can name that stores rows: tables, partitioned tables, materialized views, foreign tables and sequences.
120
120
  # One read that takes no lock on any of them.
121
121
  def relation_columns(conn, schemas = [])
122
122
  rows = conn.select_rows(
123
123
  "SELECT #{SCHEMA}, c.relname, a.attname FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid " \
124
- "JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p', 'm', 'f') AND a.attnum > 0 " \
124
+ "JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p', 'm', 'f', 'S') AND a.attnum > 0 " \
125
125
  "AND NOT a.attisdropped AND #{in_schemas(conn, schemas)} ORDER BY c.relname, a.attnum"
126
126
  )
127
127
  rows.each_with_object({}) do |(schema, relation, column), found|
@@ -16,7 +16,7 @@ module RailsAiContext
16
16
 
17
17
  # What the child writes where a result's length would stand, the moment it meets a lock.
18
18
  MET_LOCK = [ (1 << 64) - 1 ].pack("Q>").freeze
19
- # What it writes once the statement is prepared: the schema is read, so a lock met on the way there is behind it.
19
+ # What it writes once it holds its read lock: no lock can stop the statement from there on.
20
20
  PAST_LOCK = [ (1 << 64) - 2 ].pack("Q>").freeze
21
21
  WAITS_ON_LOCK = { MET_LOCK => true, PAST_LOCK => false }.freeze
22
22
 
@@ -33,7 +33,7 @@ module RailsAiContext
33
33
  def read_only(conn, sql, timeout)
34
34
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
35
35
  # A child opens the file itself and would answer without the rows of a transaction open here.
36
- if !conn.transaction_open? && (path = fork_path(conn))
36
+ if !conn.transaction_open? && (path = fork_path(conn)) && !DatabaseFamily.bounding { held_here?(conn, sql) }
37
37
  result = in_child(conn, path, sql, deadline)
38
38
  return Rows.new(result.columns, result.rows, false) if result
39
39
  end
@@ -42,6 +42,21 @@ module RailsAiContext
42
42
  Rows.new(result.columns, result.rows, true)
43
43
  end
44
44
 
45
+ # The temp catalog, the temp schema, and a pragma function or dbstat: each reads any schema, by a name that can be put
46
+ # together as the statement runs.
47
+ CONNECTION_CATALOG = /(?<![\w$])(?:sqlite_temp_(?:master|schema)|temp["`'\]]?\s*\.|pragma_\w|dbstat(?![\w$]))/i
48
+
49
+ # Whether the statement names what the connection holds and a child would answer wrongly: a temp object, the temp schema or
50
+ # an attached database, in a string too. A table only an attached database holds needs no match: no child can prepare it.
51
+ def held_here?(conn, sql)
52
+ # Neither read waits on another connection's lock, which the pragma function for the same list does.
53
+ names = conn.select_rows("PRAGMA database_list").filter_map { |_seq, name, _file| name unless OWN_SCHEMAS.include?(name) } +
54
+ conn.select_values("SELECT name FROM sqlite_temp_master")
55
+ return false if names.empty?
56
+
57
+ sql.match?(CONNECTION_CATALOG) || SecretColumns.fold(sql).match?(Regexp.union(names.map { |name| SecretColumns.identifier(name) }))
58
+ end
59
+
45
60
  # The watchdog's interrupt lands when the driver is back in Ruby between two rows, so it ends a
46
61
  # statement that returns rows slowly and cannot end one long step: the run counts as unbounded.
47
62
  def on_app_connection(conn, sql, deadline)
@@ -184,7 +199,7 @@ module RailsAiContext
184
199
 
185
200
  # pragma_table_list, which types a shadow table, came with this version.
186
201
  TYPED_TABLES = "3.37"
187
- VOCABULARY = /\bUSING\s+["'`\[]?(fts5vocab|fts4aux)\b["'`\]]?\s*\((.*)\)/mi
202
+ VOCABULARY = /\A\s*USING\s+["'`\[]?(fts5vocab|fts4aux)\b["'`\]]?\s*\((.*)\)/mi
188
203
 
189
204
  # Modules that return the database file's pages or cells, whatever table they belong to.
190
205
  PAGE_MODULES = %w[sqlite_dbpage sqlite_dbdata sqlite_dbptr].freeze
@@ -193,7 +208,8 @@ module RailsAiContext
193
208
  def index_tables(conn, schemas = [])
194
209
  tables = ->(kept) { across(schemas) { |schema, _| "SELECT name, sql FROM #{schema}.sqlite_master WHERE type = 'table' AND rootpage #{kept}" } }
195
210
  virtual = conn.select_rows(tables.call("= 0")).to_h
196
- stored = if conn.database_version >= TYPED_TABLES then conn.select_values("SELECT name FROM pragma_table_list WHERE type = 'shadow'")
211
+ stored = if conn.database_version >= TYPED_TABLES
212
+ conn.select_values(across(schemas) { |_, name| "SELECT name FROM pragma_table_list WHERE type = 'shadow' AND schema = #{name} COLLATE NOCASE" })
197
213
  else conn.select_rows(tables.call("> 0")).map(&:first)
198
214
  end
199
215
  found = stored.each_with_object(PAGE_MODULES.to_h { |name| [ name, [ :pages, nil ] ] }) do |name, tables|
@@ -201,14 +217,24 @@ module RailsAiContext
201
217
  tables[name] = [ :shadow, owner ] if owner
202
218
  end
203
219
  virtual.each do |name, sql|
204
- mod, args = sql.to_s.match(VOCABULARY)&.captures
220
+ clause = module_clause(SqlGate.comment_free(sql.to_s, SQLITE))
221
+ mod, args = clause.match(VOCABULARY)&.captures
205
222
  # fts5vocab([schema,] table, type) and fts4aux([schema,] table).
206
- names = args.to_s.split(",").map { |arg| arg.strip.delete(%q('"`[])) }
207
- found[name] = mod ? [ :vocabulary, mod.casecmp?("fts4aux") ? names.last : names[-2], sql ] : [ :virtual, name, sql ]
223
+ names = args.to_s.scan(SecretColumns::MODULE_ARGUMENT).map { |arg| arg.strip.delete(%q('"`[])) }
224
+ found[name] = mod ? [ :vocabulary, mod.casecmp?("fts4aux") ? names.last : names[-2], clause ] : [ :virtual, name, clause ]
208
225
  end
209
226
  found
210
227
  end
211
228
 
229
+ # What a virtual table's definition says after the table's own name: `USING module(arguments)`.
230
+ # SQLite stores it from the name on, and a quoted name can hold the word USING.
231
+ def module_clause(sql)
232
+ scanner = SqlGate::Scanner.new(sql)
233
+ scanner.skip(/\s*CREATE\s+VIRTUAL\s+TABLE\s+/i)
234
+ SqlGate.quoted(scanner, SQLITE) || scanner.skip(/[^\s"'`\[]+/)
235
+ scanner.rest
236
+ end
237
+
212
238
  def table_sizes(conn)
213
239
  # dbstat lists every b-tree, indexes and SQLite internals too.
214
240
  app_tables = conn.select_values("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").to_set
@@ -277,8 +303,11 @@ module RailsAiContext
277
303
  payload = begin
278
304
  db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
279
305
  wait_on_lock(db, writer, timeout * 0.8)
280
- stmt = db.prepare(sql)
306
+ # A read inside a transaction keeps its lock, so the statement itself never waits on one.
307
+ db.execute("BEGIN")
308
+ db.execute("SELECT 1 FROM sqlite_master LIMIT 1")
281
309
  writer.write(PAST_LOCK)
310
+ stmt = db.prepare(sql)
282
311
  [ :ok, stmt.columns, stmt.to_a ]
283
312
  rescue => e
284
313
  # Which step failed is the child's own fact; the message can hold text from the statement.
@@ -293,9 +322,8 @@ module RailsAiContext
293
322
  exit!(0)
294
323
  end
295
324
 
296
- # Waits on a lock for the seconds given, under the deadline, so a database that stays locked is answered as
297
- # locked before the parent stops waiting. The parent is told at the first wait: a child that started late
298
- # is still waiting when the deadline passes.
325
+ # Gives up under the deadline, so the child answers a database that stays locked. The parent is told at
326
+ # the first wait, for a child that started late and is still waiting at the deadline.
299
327
  def wait_on_lock(db, writer, seconds)
300
328
  met = nil
301
329
  db.busy_handler do |count|
@@ -56,6 +56,11 @@ module RailsAiContext
56
56
  postgresql?
57
57
  end
58
58
 
59
+ # A block comment inside a block comment needs a closer of its own.
60
+ def nested_comments?
61
+ postgresql?
62
+ end
63
+
59
64
  # `/*! ... */` holds text the database runs.
60
65
  def executable_comments?
61
66
  mysql?
@@ -100,8 +105,8 @@ module RailsAiContext
100
105
  runtime.column_sources(connection, sql, plan)
101
106
  end
102
107
 
103
- # Every schema read below takes the schemas to read. With none it reads what a statement reaches with
104
- # no schema named: the PostgreSQL search path, the MySQL connection's database, SQLite's main and temp.
108
+ # A schema read that is given no schemas reads at least what a statement reaches with no schema named:
109
+ # the PostgreSQL search path, the MySQL connection's database, SQLite's main and temp.
105
110
 
106
111
  # @return [Array<String>] the schemas a statement reaches with no schema named, where the reads' own
107
112
  # default does not cover them: SQLite's attached databases
@@ -137,9 +142,8 @@ module RailsAiContext
137
142
  runtime.generated_columns(connection, schemas)
138
143
  end
139
144
 
140
- # @return [Hash{String => Array(Symbol, String)}] table => [:pages, :shadow, :vocabulary or :virtual, the virtual
141
- # table it belongs to]: what holds another table's rows or words under other names. A virtual table and a
142
- # vocabulary also carry their definition.
145
+ # @return [Hash{String => Array}] what holds another table's rows or words under other names: table =>
146
+ # [:pages, :shadow, :vocabulary or :virtual, its virtual table, and for the last two kinds their definition]
143
147
  def index_tables(connection, schemas = [])
144
148
  runtime.index_tables(connection, schemas)
145
149
  end
@@ -105,17 +105,29 @@ module RailsAiContext
105
105
  one.delete("_").casecmp?(other.delete("_"))
106
106
  end
107
107
 
108
- # A loaded class that answers a name no constant carries belongs to no
109
- # file - Rails names the anonymous join class it builds for a
110
- # has_and_belongs_to_many through a singleton `name=`, so it answers
111
- # "HABTM_Tags" while it lives at "Account::HABTM_Tags". Keyed by that
112
- # name it would overwrite the real entry, or collapse two owners of one
113
- # association name onto one.
108
+ # A loaded class that answers a name no constant carries belongs to no file: the join class Rails names
109
+ # "HABTM_Tags" through a singleton `name=`, or a class whose constant was removed and is not yet collected.
114
110
  #
115
111
  # @param klass [Class] a loaded class
116
112
  # @return [Boolean]
117
113
  def renamed?(klass)
118
- klass.name != klass.to_s
114
+ scope = Object
115
+ klass.name.to_s.split("::").each do |part|
116
+ return true unless scope.const_defined?(part, false)
117
+ # Asking loads no file: a constant still waiting to autoload is taken to name the class.
118
+ return false if scope.autoload?(part, false)
119
+
120
+ scope = scope.const_get(part, false)
121
+ end
122
+ !scope.equal?(klass)
123
+ rescue NameError
124
+ # A name that is no constant path, or a path through a value that is no module.
125
+ true
126
+ end
127
+
128
+ # The loaded classes among these that their own constants still name.
129
+ def carried(classes)
130
+ classes.reject { |klass| renamed?(klass) }
119
131
  end
120
132
 
121
133
  # @return [Boolean] whether the source declares a class at all. A file
@@ -79,10 +79,10 @@ module RailsAiContext
79
79
  JOB_DIRS.each { |kind| EagerLoad.dir(app.root, kind: kind) }
80
80
 
81
81
  ActiveJob::Base.descendants.filter_map do |job|
82
- next if job.name.nil? || job.name == "ApplicationJob" ||
82
+ next if job.name.nil? || job.name == "ApplicationJob" || DeclaredConstant.renamed?(job) ||
83
83
  job.name.start_with?("ActionMailer", "ActiveStorage::", "ActionMailbox::", "Turbo::", "Sentry::")
84
84
  next unless app_defined?(job)
85
- if SuperclassChain.abstract_base?(job.name, inherited: job.descendants.any?)
85
+ if SuperclassChain.abstract_base?(job.name, inherited: DeclaredConstant.carried(job.descendants).any?)
86
86
  reflected_bases << { name: job.name, file: source_file_for(job), active_job: true }
87
87
  next
88
88
  end
@@ -743,7 +743,7 @@ module RailsAiContext
743
743
  def booted_mailer_base(mailer)
744
744
  file = source_file_for(mailer)
745
745
  source = file && SafeFile.read(File.join(app.root.to_s, file))
746
- heirs = mailer.descendants.filter_map(&:name)
746
+ heirs = DeclaredConstant.carried(mailer.descendants).map(&:name)
747
747
  return { name: mailer.name, file: file, inherited_by: heirs.sort.presence }.compact unless source
748
748
 
749
749
  walked = SourceIntrospector.walk_source(source, {
@@ -765,7 +765,7 @@ module RailsAiContext
765
765
  ActionMailer::Base.descendants.filter_map do |mailer|
766
766
  # `descendants` is every mailer in the process, and a gem's is not
767
767
  # the app's however it is named.
768
- next if mailer.name.nil? || !app_defined?(mailer)
768
+ next if mailer.name.nil? || DeclaredConstant.renamed?(mailer) || !app_defined?(mailer)
769
769
 
770
770
  # Reflection with the app-base subtraction: `action_methods` stops
771
771
  # subtracting at the framework base, so a public helper on a
@@ -776,7 +776,7 @@ module RailsAiContext
776
776
  )
777
777
  # A base other mailers inherit from is where the helpers they share
778
778
  # live. Rails dispatches on those, and no reader sends one.
779
- if SuperclassChain.abstract_base?(mailer.name, inherited: mailer.descendants.any?)
779
+ if SuperclassChain.abstract_base?(mailer.name, inherited: DeclaredConstant.carried(mailer.descendants).any?)
780
780
  bases << booted_mailer_base(mailer)
781
781
  next
782
782
  end
@@ -1279,7 +1279,7 @@ module RailsAiContext
1279
1279
  EagerLoad.dir(app.root, kind: "app/channels")
1280
1280
 
1281
1281
  ActionCable::Channel::Base.descendants.filter_map do |channel|
1282
- next if channel.name.nil? || channel.name == "ApplicationCable::Channel"
1282
+ next if channel.name.nil? || channel.name == "ApplicationCable::Channel" || DeclaredConstant.renamed?(channel)
1283
1283
 
1284
1284
  file = source_file_for(channel)
1285
1285
  macros = channel_sources.find { |klass| klass.file == file }&.macros ||
@@ -629,10 +629,9 @@ module RailsAiContext
629
629
  next unless klass < ActiveRecord::Base && !klass.abstract_class?
630
630
  models << klass
631
631
  known << class_name
632
- rescue NameError, LoadError, ScriptError => e
633
- # A syntax-broken file costs itself, not the whole listing, but
634
- # its name is recorded: a file that exists for a class reflection
635
- # lacks is not the same answer as no such model.
632
+ rescue StandardError, ScriptError => e
633
+ # A file that raises as it loads costs itself alone, and its name is recorded:
634
+ # a file that exists for a class reflection lacks is no missing model.
636
635
  @unloadable[class_name] = e.message.to_s.lines.first.to_s.strip
637
636
  @outside_model_dirs << class_name unless model_dir_file?(record.path)
638
637
  end
@@ -987,7 +986,7 @@ module RailsAiContext
987
986
  bases = abstract_bases(model)
988
987
  return bases unless model.superclass == ActiveRecord::Base
989
988
 
990
- bases | ActiveRecord::Base.subclasses.select { |klass| klass.name && klass.abstract_class? }
989
+ bases | DeclaredConstant.carried(ActiveRecord::Base.subclasses).select(&:abstract_class?)
991
990
  end
992
991
 
993
992
  def every_model_modules
@@ -1137,13 +1136,7 @@ module RailsAiContext
1137
1136
 
1138
1137
  return nil unless has_type_column
1139
1138
 
1140
- children = if model.respond_to?(:descendants)
1141
- model.descendants.map(&:name).compact.sort
1142
- elsif model.respond_to?(:subclasses)
1143
- model.subclasses.map(&:name).compact.sort
1144
- else
1145
- []
1146
- end
1139
+ children = DeclaredConstant.carried(model.descendants).map(&:name).sort
1147
1140
 
1148
1141
  parent = model.superclass
1149
1142
  sti_parent = if parent && parent != ActiveRecord::Base &&