rails-ai-context 5.36.0 → 5.36.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ea836548c12d9e6a022ca0e9cb78f471c2144d5b16197ab9493808209c12f74
4
- data.tar.gz: 1037665358df3ef107d83530dcec2371728b76df61852c51387b2797eb5bac51
3
+ metadata.gz: 0a04632cfd1114032ce4434e7e8b99d5f92c81bc9537bf5c65b6c65a3124c278
4
+ data.tar.gz: 3c5210bcb7ba0fef976a00b1224647588d44556bb8d807cbbc87aca9ff0214ce
5
5
  SHA512:
6
- metadata.gz: 007d9bbd31542ad5caa6847ce83443fa3d793483cbb93aefcd37dc03c4f4cbf536e51dfd4735ddcf27e0f6dda46b052a95f7f6317d2685f2b2cb8769da349819
7
- data.tar.gz: a4815b990a54d309fe3b5a4ce97ab8b7fca7a50841a70825d3aa02378915fe77f0f5465259e3484efb2a8268e959f4877bde376a4617ee461e36e94292eb4301
6
+ metadata.gz: 41e3246dc1905379a9d39bc4bfb8e95498b752a95f1a96b2e955ad58ca22a06696d0c12fd1b676e7414086cea5e571f772c8638dec36823ee542462dca8f4e64
7
+ data.tar.gz: ea9ebf310554c650503a9f9b95521988eccbf42286885b294ae86031d8c393a3dc71161ecab3852bb69a17055167b5334669fd0187817c62c73f98ecbf0843c1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.36.2] - 2026-10-15
9
+
10
+ ### Fixed
11
+
12
+ - **`rails_onboard` and `rails_get_schema` name every database
13
+ `rails_performance_check` names.** A database database.yml configures with no
14
+ dump on disk and no migrations of its own, or with `database_tasks: false` and
15
+ no committed dump, was left out of both. `rails_onboard` now lists it as
16
+ "(tables not read)", and `rails_get_schema` says why: "Its tables are not
17
+ listed: db/extra_schema.rb is not on disk". A replica stays out of all three,
18
+ and so does a database whose dump is the primary's. The schema rules file
19
+ gives the primary no heading of its own when it is the only database with
20
+ tables. For a `schema_dump` the gem cannot follow, the reason now reads
21
+ "`schema_dump` in its database.yml entry names no file to read" on
22
+ `rails_get_schema` and `rails_performance_check`. 5.36.1 and earlier versions
23
+ had the gap.
24
+ - **On the static tier a model of the primary database whose table db/schema.rb
25
+ does not declare says so.** `rails_get_model_details`, `rails_onboard` and the
26
+ generated files named the conventional table, `widgets`, with no word that
27
+ db/schema.rb holds no such table, and model details pointed to
28
+ `rails_get_schema` for it. They now answer "`widgets` missing from
29
+ db/schema.rb", and model details leaves out the pointer for a table it says is
30
+ missing. A model whose table or database the static tier cannot be sure of is
31
+ left unjudged: one of another database, one on a connection of its own
32
+ (`establish_connection` in its class or a base), one whose table name a
33
+ computed `table_name`, affix or `pluralize_table_names` shapes, one with a
34
+ concern or base the static tier could not read, a table in another schema, and
35
+ tables read from db/structure.sql or the migrations, since a replay can miss a
36
+ table raw SQL creates. 5.36.1 and earlier versions had the gap.
37
+ - **The static tier reads the table name prefix of a module whose name camelize
38
+ does not give.** A module file such as app/models/sdg.rb that declares
39
+ `module SDG` with `table_name_prefix` was keyed as `Sdg`, so `SDG::Goal` got
40
+ the table `goals` where Rails reads `sdg_goals`. The module is now named as it
41
+ declares itself. 5.36.1 and earlier versions had the gap.
42
+ - **The static tier builds no table name from an affix it could not read.**
43
+ `ActiveRecord::Base.table_name_prefix = ENV.fetch("P", "")` gave every model a
44
+ table such as `[INFERRED]widgets`. An affix or `pluralize_table_names` the app
45
+ computes is now left out of the guessed name, and a later computed assignment
46
+ hides an earlier literal one, as it does when the app runs. 5.36.1 and earlier
47
+ versions had the gap.
48
+ - **`rails_session_context(mark:)` says a tool skip_tools turned off is turned
49
+ off.** It answered "Unknown tool" for a tool the gem has, where a call of the
50
+ tool says it is turned off. Both now give the same answer, by the tool's full
51
+ or short name. 5.36.1 and earlier versions had the gap.
52
+
53
+ ## [5.36.1] - 2026-10-15
54
+
55
+ ### Fixed
56
+
57
+ - **On PostgreSQL the refusal of a name on the read-only VOLATILE list says the
58
+ list trusts the server's own function or a C function.** 5.36.0 answered "an
59
+ extension's C function", narrower than the rule it applies: any C function of
60
+ that name is trusted, since only a superuser can create one.
61
+ - **The docs say which reads of a `rails_query` call run with no time limit.**
62
+ `docs/TOOLS.md` and `docs/SECURITY.md` said every statement a call sends is
63
+ bounded, and the reads of a setting's own value for the settings note, new in
64
+ 5.36.0, are not. They wait on no lock. `docs/SECURITY.md` also said `PRAGMA
65
+ query_only` is refused by name: a `PRAGMA` statement is no read, and the
66
+ `pragma_query_only` function is the one refused by name.
67
+ - **`docs/TOOLS.md` says when `rails_get_mailers` names where a mailer's actions
68
+ come from.** A mailer with no action of its own names a gem base it reaches
69
+ without passing `ApplicationMailer`, such as `Devise::Mailer`. It names none
70
+ past `ApplicationMailer` and never Rails' own base, on both tiers, as 5.36.0
71
+ does. The page said any gem base was named.
72
+
8
73
  ## [5.36.0] - 2026-10-14
9
74
 
10
75
  ### Security
@@ -323,6 +323,16 @@ Proof sources:
323
323
  11.4 on Rails 7.0 (mysql2), 8.0 and 8.1 (mysql2 and Trilogy), and on
324
324
  PostgreSQL 13, 14, 15, 17 and 18 on Rails 8.0; and the unit suite and the
325
325
  E2E harness on Linux, macOS and Windows.
326
+ In the v5.36.1 release QA: every issue repro of 5.36.0 run again on the
327
+ published code on PostgreSQL 13 to 18, MySQL 8.0 and 8.4 and MariaDB 10.6
328
+ and 11.4, the booted and static mailer lists compared on one app, and the
329
+ unit suite and the E2E harness on Linux, macOS and Windows.
330
+ In the v5.36.2 release QA: each fix's example failing on the v5.36.1 code;
331
+ the issues' repros run through the CLI on a minimal app on the v5.36.1 code
332
+ and on the fix; the static answers of 13 corpus apps compared between the
333
+ two; MariaDB 10.6 and 11.4 through mysql2 built against MariaDB's client
334
+ library on Linux; `rails_runtime_info` against a real Sidekiq and Redis; and
335
+ the unit suite and the E2E harness on Linux, macOS and Windows.
326
336
  2. Non-crash coverage for every built-in tool including `get_view` in
327
337
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
328
338
  (ivar cross-check, render-form detection, partial interfaces) verified
@@ -184,7 +184,7 @@ It runs a single-pass Dispatcher that walks the AST once and feeds events to all
184
184
  | ScopesListener | `scope :name, -> { ... }`, `lambda { ... }` and the block form; a scope declared in an `each` over a literal list, inline or a constant the file assigns (`KINDS.each { \|k\| scope k, -> { ... } }`), as one scope per element, tagged `[INFERRED]` |
185
185
  | EnumsListener | Rails 7+ and legacy enum syntax, prefix/suffix options |
186
186
  | CallbacksListener | All AR callback types including `around_*`, `after_touch`, `after_initialize` and `after_find`; `after_commit` with `on:` resolution; a callback object as the source wrote it (`Normalizer`, `Normalizer.new`), a block as `[inline_block]` with its declaration as written; `if:`/`unless:` kept as the source wrote them, and the options of an enclosing `with_options` block; the commit callbacks turbo-rails' `broadcasts`, `broadcasts_to`, `broadcasts_refreshes` and `broadcasts_refreshes_to` declare, each with the method it runs; `before_`/`after_`/`around_<event>` on a chain `define_model_callbacks` makes, kept when the same file defines it (honouring `only:`), and for a chain a library defines elsewhere (Paperclip's `before_<attachment>_validate`, `after_post_process`) only when the model walk asks, so the booted tier keeps the ones whose chain the model's `__callbacks` holds |
187
- | MacrosListener | (each attribute macro and `has_secure_password` with its keyword options as written, under `written`) `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute`, `alias_attribute`, `store_accessor`, `self.ignored_columns`, `has_secure_token`, `accepts_nested_attributes_for`, `attr_readonly`, `query_constraints`, the turbo-rails broadcast macros (`broadcasts`, `broadcasts_to`, `broadcasts_refreshes`, `broadcasts_refreshes_to`), `connects_to` as written (a child carries its base's, with the base's name under `declared_in`), the class settings `self.inheritance_column`/`store_full_sti_class`/`strict_loading_by_default`/`implicit_order_column`/`locking_column =` as written, an `aasm` block's states, initial state, events and transitions, and model gems' class macros (`GEM_MACROS`: `has_paper_trail`, `friendly_id`, `mount_uploader`, `monetize`, `pg_search_scope`, `acts_as_list` and others) as written, without their block (Paperclip's `has_attached_file` by its name and attachment only) |
187
+ | MacrosListener | (each attribute macro and `has_secure_password` with its keyword options as written, under `written`) `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute`, `alias_attribute`, `store_accessor`, `self.ignored_columns`, `has_secure_token`, `accepts_nested_attributes_for`, `attr_readonly`, `query_constraints`, the turbo-rails broadcast macros (`broadcasts`, `broadcasts_to`, `broadcasts_refreshes`, `broadcasts_refreshes_to`), `connects_to` as written (a child carries its base's, with the base's name under `declared_in`), `establish_connection` (on the static tier a model whose class or base calls it is on a connection of its own), the class settings `self.inheritance_column`/`store_full_sti_class`/`strict_loading_by_default`/`implicit_order_column`/`locking_column =` as written, an `aasm` block's states, initial state, events and transitions, and model gems' class macros (`GEM_MACROS`: `has_paper_trail`, `friendly_id`, `mount_uploader`, `monetize`, `pg_search_scope`, `acts_as_list` and others) as written, without their block (Paperclip's `has_attached_file` by its name and attachment only) |
188
188
  | MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self`, the methods `delegate` and Forwardable's `def_delegators`/`def_delegator` define (public unless `private: true`, and with no `end_location`, since a delegation has no body), and the methods `alias`, `alias_method`, `attr_*`, `define_method` with a literal name, `class_attribute` and `cattr_*`/`mattr_*` define (an alias keeps its original's visibility and parameters; Active Support's accessors are public on the class and, unless an option turns them off, on instances); each `def` carries `offset`/`end_offset`, which `SourceIntrospector.outside_defs` pairs against a call's `offset` to tell a call on the same line as a def from one inside it; a `Struct.new`/`Class.new`/`Module.new`/`Data.define` block assigned to a constant is that constant's body, and a `def self.x` in `included do` is marked `class_methods_block` like a `def x` in `class_methods do`; `include_initialize: true` adds the constructor a caller reports on its own |
189
189
  | MixinsListener | `include`, `prepend`, `extend`, `singleton_class.include` and `singleton_class.prepend` (`singleton_class.extend` is not read), flagging the ones that reach the ancestor chain, as reflection reports them. GitLab's `prepend_mod_with("Note")`, `include_mod_with`, `extend_mod_with` and their `_mod` forms record `EE::Note` and `JH::Note` flagged `edition`; `ConcernMembership.owned_by` keeps one only where the app has its file, and credits `Note.prepend X` to Note |
190
190
 
@@ -271,7 +271,7 @@ same wherever it is asked. Those live as their own modules under
271
271
  | `ActionPresence` | Whether a controller has an action: the public methods and `define_method` names of the controller, its ancestors up to Rails' base and the modules they include, the templates at each ancestor's view prefix, and the ancestors or modules no app source holds, which leave a missing action unverified. `rails_validate` and `rails_generate_test` both ask it |
272
272
  | `ControllerSettings` | The layout a controller renders in (declared on it or an ancestor, else the `layouts/<controller_path>` file Rails finds by name, walking up the chain) and the `allow_browser`, `protect_from_forgery`, `add_flash_types`, `default_form_builder` and `wrap_parameters` calls it and its ancestors make. `rails_get_controllers` and `rails_get_view` both ask it |
273
273
  | `TableName` | The table a model reads, from its own declarations |
274
- | `ActiveRecordSettings` | What config/application.rb, the environment file and the initializers set on Active Record: the table affixes, `pluralize_table_names` and `schema_format`, in the order Rails applies them. Read once per introspection run or tool call |
274
+ | `ActiveRecordSettings` | What config/application.rb, the environment file and the initializers set on Active Record: the table affixes, `pluralize_table_names` and `schema_format`, in the order Rails applies them, and which of them an assignment gives a value only the running app computes. Read once per introspection run or tool call |
275
275
  | `HabtmJoinTables` | The join tables every `has_and_belongs_to_many` under the app's code and lib names, lib patches and engines included, for the schema's model-less table warning |
276
276
  | `SuperclassChain` | What a class inherits from, followed through the app's own sources: the chain from a file's class up to a named base, and the constant-to-source lookup over the app's autoload roots that walks it |
277
277
  | `CallSiteExpansion` | What one call of a mixin's macro-declaring method declares. `attachable :receipt, has_one: true` runs `def attachable(name, opts = {})`, whose body declares `has_one name` or `has_many name` by `opts[:has_one]`: the parameters bind to the call's literal arguments, and a branch (`if`, `unless`, `case`) the literals decide is taken alone. Where they cannot decide, what every way through declares alike is listed once; the rest, a lone `if`'s body included, comes back under `:conditional` with its condition and is not counted. A block the method evaluates on another receiver (`other.instance_eval`) comes back under `:foreign`, named with the receiver and any condition it runs under |
data/docs/SECURITY.md CHANGED
@@ -85,7 +85,7 @@ The textual layer cannot see a whole-row serialisation (`SELECT row_to_json(u.*)
85
85
  - holds a **whole-row reference** (`alias.*`) to a relation that has a secret column - this closes `row_to_json(u)`, `to_json(u)`, `u::text`, `array_agg(u)`, `json_agg(u)`, `hstore(u)`, `concat(u.*)`, `string_agg(u::text, ...)`, a bare `SELECT u`, and the same through a view, subquery or CTE. An alias is read as the plan prints it, so two aliases that differ only by case are two relations. The planner numbers an alias a statement uses twice (`p_1` for the `p` inside a subquery), and that name stands for its own relation alone. The number is taken off only for the members of an Append or a Merge Append, which is how the partitions of one table are named. The refusal names the relation whose row the statement reads;
86
86
  - **uses a secret column** anywhere but as a plain pass-through output - a `ROW(u.*)` the planner expanded into named columns, or a `WHERE` / `ORDER BY` oracle, an ordinal `ORDER BY 3` over a wildcard included. The statement a `postgres_fdw` foreign scan sends to the remote server (the plan's `Remote SQL`) is read the same way, so a condition, a sort or a group on a secret column of a foreign table is refused where the remote server would run it. A column that carries another name on the remote server (the `column_name` option) is told by one more catalog read, made only for a plan that holds a foreign scan. A stated limit: only the remote statement of `postgres_fdw` is read, and another foreign data wrapper prints keys of its own. A join that `postgres_fdw` runs on the remote server has one scan for all its tables, which names them with their aliases in its `Relations` line: they are read from there, so a whole row of one is refused and a column of one is traced and filtered like a local table's;
87
87
  - **passes a secret column** through an intermediate the plan's top node does not name - a `MATERIALIZED` CTE, a subquery scan, or a set operation (`INTERSECT` / `EXCEPT`) - so its value would reach a result column that the output-provenance redaction (Layer 4) cannot pin. These are refused, since no redaction could reach them. A column that a scan hands to an aggregate, a join or a Result node is counted only when that node names it, so `count(*)` over a table with a secret column runs, and so does an `EXISTS` inside an `EXISTS`, whose plan puts a Result over the inner scan;
88
- - names any **VOLATILE function** (`pg_proc.provolatile = 'v'`) outside a short read-only allowlist. `pg_proc` marks a function VOLATILE when its answer may change within one statement, which is as true of a reader of live server state as of a writer, so VOLATILE alone does not mean "changes state". The allowlist holds only built-in functions and extension C functions that write nothing: value generators (`random`, `clock_timestamp`, `gen_random_uuid`, `timeofday`, `uuid_generate_v4`, and `pg_sleep*`, which the statement timeout bounds); the size readers (`pg_total_relation_size`, `pg_relation_size`, `pg_table_size`, `pg_indexes_size`, `pg_database_size`, `pg_tablespace_size`); the lock-table snapshot readers (`pg_lock_status` - what the `pg_locks` view reads - `pg_blocking_pids`, `pg_safe_snapshot_blocking_pids`); and the recovery / WAL-position readers (`pg_is_in_recovery`, `pg_is_wal_replay_paused`, `pg_get_wal_replay_pause_state`, `pg_current_wal_lsn`, `pg_current_wal_insert_lsn`, `pg_current_wal_flush_lsn`, `pg_last_wal_receive_lsn`, `pg_last_wal_replay_lsn`, `pg_last_xact_replay_timestamp`); and `pg_sequence_last_value`, which reads a sequence's stored value the way `SELECT last_value FROM a_sequence` does and is what the `pg_sequences` view reads. A name on the list vouches for the server's own function and an extension's C function alone, such as `uuid_generate_v4`: while the database holds a VOLATILE function of that name in another language outside `pg_catalog`, every call of the name is refused. The functions the statement's text names are checked beside those the plan prints, since a plan prints no row of a `VALUES` list and no `LIMIT` expression. The name after `TABLESAMPLE` is a sampling method, whose handler the statement never calls, so it is not read as a call; the method's arguments and its `REPEATABLE` seed are, as the plan prints them. Anything else VOLATILE is not run, and the refusal says plainly that the function is VOLATILE and outside the read-only list, or, for a name on the list, that the database holds another VOLATILE function of that name. This closes the admin-function family without a name list that has to grow with every PostgreSQL release.
88
+ - names any **VOLATILE function** (`pg_proc.provolatile = 'v'`) outside a short read-only allowlist. `pg_proc` marks a function VOLATILE when its answer may change within one statement, which is as true of a reader of live server state as of a writer, so VOLATILE alone does not mean "changes state". The allowlist holds only built-in functions and extension C functions that write nothing: value generators (`random`, `clock_timestamp`, `gen_random_uuid`, `timeofday`, `uuid_generate_v4`, and `pg_sleep*`, which the statement timeout bounds); the size readers (`pg_total_relation_size`, `pg_relation_size`, `pg_table_size`, `pg_indexes_size`, `pg_database_size`, `pg_tablespace_size`); the lock-table snapshot readers (`pg_lock_status` - what the `pg_locks` view reads - `pg_blocking_pids`, `pg_safe_snapshot_blocking_pids`); and the recovery / WAL-position readers (`pg_is_in_recovery`, `pg_is_wal_replay_paused`, `pg_get_wal_replay_pause_state`, `pg_current_wal_lsn`, `pg_current_wal_insert_lsn`, `pg_current_wal_flush_lsn`, `pg_last_wal_receive_lsn`, `pg_last_wal_replay_lsn`, `pg_last_xact_replay_timestamp`); and `pg_sequence_last_value`, which reads a sequence's stored value the way `SELECT last_value FROM a_sequence` does and is what the `pg_sequences` view reads. A name on the list vouches for the server's own function or a C function alone, such as an extension's `uuid_generate_v4`: while the database holds a VOLATILE function of that name in another language outside `pg_catalog`, every call of the name is refused. The functions the statement's text names are checked beside those the plan prints, since a plan prints no row of a `VALUES` list and no `LIMIT` expression. The name after `TABLESAMPLE` is a sampling method, whose handler the statement never calls, so it is not read as a call; the method's arguments and its `REPEATABLE` seed are, as the plan prints them. Anything else VOLATILE is not run, and the refusal says plainly that the function is VOLATILE and outside the read-only list, or, for a name on the list, that the database holds another VOLATILE function of that name. This closes the admin-function family without a name list that has to grow with every PostgreSQL release.
89
89
 
90
90
  Every statement except `SHOW` is planned, and an `EXPLAIN` is planned as the statement it explains. `EXPLAIN (GENERIC_PLAN)` is planned with that option too, read as the server reads it (its last value, quoted or not), so a statement with `$1` in it is planned as PostgreSQL 16 and later plan it. A statement whose plan cannot be read does not run: when planning times out the answer is the timeout, when planning fails it is the SQL error, and when the server returns no plan the statement is refused.
91
91
 
@@ -148,7 +148,7 @@ What bounds each statement a `rails_query` call sends:
148
148
  | The query, and `explain: true` | `statement_timeout`, a wait on a lock included | the session's statement limit, and `lock_wait_timeout` for a wait on a lock | the child process's deadline |
149
149
  | The reads beside it: the schema read behind the secret column rule, and on PostgreSQL the plan, its `pg_proc` lookup and the column trace | `statement_timeout`, a wait on a lock included | the session's statement limit, and `lock_wait_timeout` for a wait on a lock | no statement limit; a wait on another writer ends at the connection's busy timeout |
150
150
 
151
- The query has `query_timeout` to itself. The reads beside it share a second `query_timeout`, and each is charged the time it ran, so the statements of a call take about twice `query_timeout` at most. Building the app's context, which a cold call does first, is charged to neither, and so are the gate's checks on the statement's text, which the statement length limit bounds. The schema read is a few statements whatever the number of tables: four at most on MySQL and MariaDB, five on PostgreSQL and six on SQLite. On a SQLite file database where a query process can run, two more reads ask what the app's own connection alone holds, so eight reads come before the statement there. On PostgreSQL a plan that holds a foreign scan adds one catalog read. A statement or a view definition that names another schema or database adds the reads of that one.
151
+ The query has `query_timeout` to itself. The reads beside it share a second `query_timeout`, and each is charged the time it ran, so the statements of a call take about twice `query_timeout` at most. On PostgreSQL, MySQL and MariaDB the reads with no bound are of a setting's own value for the settings note, which wait on no lock. Building the app's context, which a cold call does first, is charged to neither, and so are the gate's checks on the statement's text, which the statement length limit bounds. The schema read is a few statements whatever the number of tables: four at most on MySQL and MariaDB, five on PostgreSQL and six on SQLite. On a SQLite file database where a query process can run, two more reads ask what the app's own connection alone holds, so eight reads come before the statement there. On PostgreSQL a plan that holds a foreign scan adds one catalog read. A statement or a view definition that names another schema or database adds the reads of that one.
152
152
 
153
153
  A read that runs out of that time ends the call with an error that says which side of the statement it was on:
154
154
 
@@ -174,7 +174,7 @@ On PostgreSQL and MySQL the query executes inside a transaction, then rolls back
174
174
 
175
175
  On a connection that is already inside a transaction (a tool called from a transactional test, a mounted server inside a request's transaction, a transaction the app opened with a raw `BEGIN` that Active Record does not count) the guard leaves that transaction open as it found it, with its writes. PostgreSQL guards inside a savepoint, chosen by the server's own transaction status, so the outer transaction stays writable. MySQL and MariaDB cannot make an open transaction read-only: the server refuses `SET TRANSACTION READ ONLY` inside one, and a `BEGIN` there would commit it, so no `BEGIN` is sent, the statement is not sent, and the answer is "Query failed: MySQL cannot make a transaction that is already open read-only, so the statement was not sent. Run it outside the transaction." SQLite runs the statement on the app's own connection, so it reads the rows of the open transaction. It runs under `PRAGMA query_only = ON`, and the pragma is put back as it was found.
176
176
 
177
- A statement that reads a setting the guard sets for it answers the guard's value: `statement_timeout` and `transaction_read_only` on PostgreSQL, the statement limit (`max_execution_time` or `max_statement_time`) and `lock_wait_timeout` on MySQL and MariaDB. When the statement reads one of them (`SHOW name`, `@@name`, `@@SESSION.name`, `@@LOCAL.name` or `current_setting('name')`, the name backquoted or not), or a `SHOW VARIABLES` row or MariaDB's `information_schema.SESSION_VARIABLES` names one, the answer ends with a note that says so and gives the session's own value, for example "statement_timeout here is the value rails_query sets for each statement it runs. The session's own: statement_timeout 50s." A name used as an alias or a column, one in a string or a comment, and a global value on MySQL and MariaDB get no note. SQLite's `PRAGMA query_only` is refused by name.
177
+ A statement that reads a setting the guard sets for it answers the guard's value: `statement_timeout` and `transaction_read_only` on PostgreSQL, the statement limit (`max_execution_time` or `max_statement_time`) and `lock_wait_timeout` on MySQL and MariaDB. When the statement reads one of them (`SHOW name`, `@@name`, `@@SESSION.name`, `@@LOCAL.name` or `current_setting('name')`, the name backquoted or not), or a `SHOW VARIABLES` row or MariaDB's `information_schema.SESSION_VARIABLES` names one, the answer ends with a note that says so and gives the session's own value, for example "statement_timeout here is the value rails_query sets for each statement it runs. The session's own: statement_timeout 50s." A name used as an alias or a column, one in a string or a comment, and a global value on MySQL and MariaDB get no note. On SQLite a `PRAGMA` statement is no read, and the `pragma_query_only` function is refused by name.
178
178
 
179
179
  An adapter with no family (SQL Server, Oracle, CockroachDB and any other) is refused: `rails_query` answers with the adapter's name and sends no statement, because it cannot give that database a read-only transaction and a timeout it has verified. The family is read from the database's configuration, so the refusal comes before any connection is opened or verified. CockroachDB's adapter is built on PostgreSQL's, but the adapter's name alone chooses the family, so it is refused too.
180
180
 
data/docs/TOOLS.md CHANGED
@@ -143,14 +143,15 @@ Narrative app walkthrough for getting up to speed. The stack line counts the
143
143
  tables of every database the app's own schema dumps declare, as
144
144
  `rails_get_schema` reads them; the databases Rails 8 gives Solid Queue, Solid
145
145
  Cache and Solid Cable get one line, without their tables. A database whose
146
- dump was not read is named with "(tables not read)" and left out of the table
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 rake tasks (Rakefile,
149
- `lib/tasks`, `rakelib`), each with its arguments, description and file: the
150
- first 15 at `standard`, every one at `full`.
151
- Then the app's own generators under `lib/generators` (the `bin/rails generate`
152
- command and its USAGE line), the `lib/templates` files that replace a built-in
153
- generator's template, and the Railties under `lib/` with their initializers.
146
+ dump was not read, or that has no dump on disk and no migrations of its own,
147
+ is named with "(tables not read)" and left out of the table count, and the
148
+ summary says so: "5 tables in 2 databases, the warehouse database not read".
149
+ It ends with the app's custom rake tasks (Rakefile, `lib/tasks`, `rakelib`),
150
+ each with its arguments, description and file: the first 15 at `standard`,
151
+ every one at `full`. Then the app's own generators under `lib/generators` (the
152
+ `bin/rails generate` command and its USAGE line), the `lib/templates` files
153
+ that replace a built-in generator's template, and the Railties under `lib/`
154
+ with their initializers.
154
155
 
155
156
  | Parameter | Type | Default | Description |
156
157
  |:----------|:-----|:--------|:------------|
@@ -176,10 +177,12 @@ The other databases are listed after the primary's tables: every database any
176
177
  environment in `config/database.yml` configures, so a Rails 8 app's queue,
177
178
  cache and cable dumps are read even where only production configures them,
178
179
  then any committed dump no environment names. A dump too large to read, or
179
- holding no tables, is named with the reason. A secondary's dump is read from
180
- its own path, else from the other format's file under its name
181
- (`db/queue_schema.rb` or `db/queue_structure.sql`), whatever `database_tasks`
182
- or `schema_format` says.
180
+ holding no tables, is named with the reason, and so is a database with no dump
181
+ on disk and no migrations of its own, one with `database_tasks: false`
182
+ included. A replica is not listed. A secondary's dump is read from its own
183
+ path, else from the other format's file under its name (`db/queue_schema.rb`
184
+ or `db/queue_structure.sql`), whatever `database_tasks` or `schema_format`
185
+ says.
183
186
 
184
187
  A database only another environment configures carries a mark, as in
185
188
  `cache (production only)`, and a first database that is not named `primary`
@@ -204,7 +207,7 @@ composite unique index; `[in index after x]` marks a column that only trails
204
207
 
205
208
  ### `rails_get_model_details`
206
209
 
207
- AST-parsed model internals. The model heading and each scope carry a confidence tag: `[VERIFIED]` or `[INFERRED]` on a booted app, `[STATIC]` without a boot. The method list says how many of the model's methods it is showing.
210
+ AST-parsed model internals. The model heading and each scope carry a confidence tag: `[VERIFIED]` or `[INFERRED]` on a booted app, `[STATIC]` without a boot. The method list says how many of the model's methods it is showing. On the static tier a model of the primary database whose table db/schema.rb does not declare says so: "`widgets` missing from db/schema.rb".
208
211
 
209
212
  | Parameter | Type | Default | Description |
210
213
  |:----------|:-----|:--------|:------------|
@@ -481,8 +484,8 @@ database's name is read whatever `database_tasks` or `schema_dump` says. A
481
484
  database whose tables could not be read is listed under **Not checked** with
482
485
  the reason: its dump is not on disk or is over `max_schema_file_size`, or its
483
486
  `config/database.yml` entry turns `database_tasks` or `schema_dump` off and no
484
- dump is on disk, or "`schema_dump` in its database.yml entry names no file this
485
- check reads". A database another environment configures carries its mark:
487
+ dump is on disk, or "`schema_dump` in its database.yml entry names no file to
488
+ read". A database another environment configures carries its mark:
486
489
  `**Not checked:** warehouse (production only, db/warehouse_schema.rb is not on disk)`.
487
490
 
488
491
  | Parameter | Type | Default | Description |
@@ -654,7 +657,7 @@ ViewComponent/Phlex components: props, slots, previews, sidecar assets, usage ex
654
657
 
655
658
  ### `rails_get_mailers`
656
659
 
657
- ActionMailer mailers: every mailer class with its delivery actions and delivery method. The actions include the public methods a mailer inherits from the app's own mailers below `ApplicationMailer`, on both tiers. A mailer that declares no action of its own - one taking them from a gem base or a mixin, or one called through class methods - is listed with where its actions come from. A base other mailers inherit from is not one: it leaves the listing, and a line above names the ones left out. Asked for by name, a base answers with what every mailer below it inherits - its layout, helpers, defaults, callbacks and mixins as the app wrote them, the methods it defines - and which mailers inherit it.
660
+ ActionMailer mailers: every mailer class with its delivery actions and delivery method. The actions include the public methods a mailer inherits from the app's own mailers below `ApplicationMailer`, on both tiers. A mailer that declares no action of its own - one taking them from a gem base or a mixin, or one called through class methods - is listed with where its actions come from when that is a gem base it reaches without passing `ApplicationMailer`, such as `Devise::Mailer`, and never Rails' own base. A base other mailers inherit from is not one: it leaves the listing, and a line above names the ones left out. Asked for by name, a base answers with what every mailer below it inherits - its layout, helpers, defaults, callbacks and mixins as the app wrote them, the methods it defines - and which mailers inherit it.
658
661
 
659
662
  Each mailer also shows its own `default`, `layout`, `helper` and action and
660
663
  deliver callbacks as written, the template formats of each action under
@@ -746,7 +749,7 @@ Safe read-only SQL with layered security: the SQL gate, a PostgreSQL plan check
746
749
 
747
750
  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.
748
751
 
749
- 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.
752
+ On PostgreSQL, MySQL and MariaDB every statement a call sends has a time limit, apart from the reads of a setting's own value for the settings note, which wait on no lock. 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.
750
753
 
751
754
  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.
752
755
 
@@ -852,7 +855,7 @@ Session-aware context tracking across tool calls within a conversation. Pass `ac
852
855
  | Parameter | Type | Default | Description |
853
856
  |:----------|:-----|:--------|:------------|
854
857
  | `action` | enum | - | `status` (queried tools with timestamps), `summary` (short recap), `reset` (clear this conversation's record) |
855
- | `mark` | string | - | Record a tool and its params as already queried (e.g., `get_schema:users`). The tool must be one the server has; a short name such as `schema` is taken. A retired name such as `get_gems` is recorded as `rails_get_config` with its section |
858
+ | `mark` | string | - | Record a tool and its params as already queried (e.g., `get_schema:users`). The tool must be one the server has; a short name such as `schema` is taken, and a tool skip_tools turned off answers that it is turned off. A retired name such as `get_gems` is recorded as `rails_get_config` with its section |
856
859
 
857
860
  Each conversation has its own record: over HTTP, one server process serves every client, and each client's `Mcp-Session-Id` keeps its calls, and its `reset`, to itself. A tool that answers by calling others (`rails_get_context`, `rails_diagnose`, `rails_review_changes`) is recorded as the one call the client made.
858
861
 
@@ -50,6 +50,21 @@ module RailsAiContext
50
50
  AppDatabases.settings_of(config)
51
51
  end
52
52
 
53
+ # Why there is no dump of this database to read, or nil when its dump is on disk.
54
+ def missing_dump(root)
55
+ return no_dump_reason unless dump
56
+ return nil if File.exist?(dump.path)
57
+
58
+ "#{dump.path.delete_prefix("#{root.to_s.chomp('/')}/")} is not on disk"
59
+ end
60
+
61
+ private def no_dump_reason
62
+ return "its database.yml entry turns `database_tasks` off, so Rails writes it no dump" unless tasks
63
+ return "its database.yml entry turns `schema_dump` off, so Rails writes it no dump" unless settings["schema_dump"]
64
+
65
+ "`schema_dump` in its database.yml entry names no file to read"
66
+ end
67
+
53
68
  # Raises Unreachable, naming the database, when no connection to it answers.
54
69
  def with_connection(&block)
55
70
  with_pool do |pool|
@@ -146,6 +146,12 @@ module RailsAiContext
146
146
  [ name, "rails_#{name}", "rails_get_#{name}" ]
147
147
  end
148
148
 
149
+ # The turned-off answer when skip_tools lists the tool a typed name spells, nil otherwise.
150
+ def self.turned_off_message(name)
151
+ skipped = candidate_names(name) & Server.skipped_tools
152
+ Server.skipped_tool_message(skipped.first) if skipped.any?
153
+ end
154
+
149
155
  private
150
156
 
151
157
  # The schema a call is checked against, as InputSchema#to_h gives it on every
@@ -178,8 +184,8 @@ module RailsAiContext
178
184
  found = tools.find { |t| self.class.short_name(t.tool_name) == name }
179
185
  return [ found, nil ] if found
180
186
 
181
- skipped = self.class.candidate_names(name) & Server.skipped_tools
182
- raise ToolNotFoundError, Server.skipped_tool_message(skipped.first) if skipped.any?
187
+ skipped = self.class.turned_off_message(name)
188
+ raise ToolNotFoundError, skipped if skipped
183
189
 
184
190
  # Fuzzy suggestion
185
191
  short_names = (tools.map(&:tool_name) | Server.retired_names).map { |tool_name| self.class.short_name(tool_name) }
@@ -15,7 +15,16 @@ module RailsAiContext
15
15
 
16
16
  # {table_name_prefix: "op_", schema_format: :sql}
17
17
  def for(root)
18
- return {} unless root
18
+ read_once(root).first
19
+ end
20
+
21
+ # The settings an assignment gives a value only the running app computes, such as `ENV.fetch`.
22
+ def unread(root)
23
+ read_once(root).last
24
+ end
25
+
26
+ def read_once(root)
27
+ return [ {}, [] ] unless root
19
28
 
20
29
  root = File.expand_path(root.to_s)
21
30
  RunCache.fetch([ :active_record_settings, root ]) { read(root) }
@@ -27,30 +36,41 @@ module RailsAiContext
27
36
  files = [ File.join(root, "config", "application.rb"), File.join(root, "config", "environments", "#{RailsAiContext.environment_name}.rb") ]
28
37
  files = files.select { |path| File.file?(path) } + PathResolver.initializer_paths(root)
29
38
  listeners = { config: -> { Listeners::ConfigAssignmentListener.new }, base: -> { Listeners::ConfigAssignmentListener.new(BASE_ROOTS) } }
30
- configured = {}
31
- direct = {}
39
+ configured, configured_unread, direct, direct_unread = {}, Set.new, {}, Set.new
32
40
  files.each do |file|
33
41
  walked = SourceIntrospector.walk(file, listeners)
34
- collect(Array(walked[:config]).filter_map { |entry| [ entry, entry[:path].last ] if entry[:path].size == 2 && entry[:path].first == :active_record }, configured)
35
- collect(Array(walked[:base]).filter_map { |entry| [ entry, entry[:path].first ] if entry[:path].size == 1 }, direct)
42
+ collect(Array(walked[:config]).filter_map { |entry| [ entry, entry[:path].last ] if entry[:path].size == 2 && entry[:path].first == :active_record }, configured, configured_unread)
43
+ collect(Array(walked[:base]).filter_map { |entry| [ entry, entry[:path].first ] if entry[:path].size == 1 }, direct, direct_unread)
44
+ end
45
+ MODULE_SETTINGS.each do |name|
46
+ next unless configured.key?(name) || configured_unread.include?(name)
47
+
48
+ direct.delete(name)
49
+ direct_unread.delete(name)
36
50
  end
37
- MODULE_SETTINGS.each { |name| direct.delete(name) if configured.key?(name) }
38
- configured.merge(direct)
51
+ [ configured.except(*direct_unread).merge(direct), (configured_unread - direct.keys | direct_unread).to_a ]
39
52
  rescue StandardError, ScriptError => e
40
- RailsAiContext.debug_fail(e, {}, label: "active_record_settings")
53
+ RailsAiContext.debug_fail(e, [ {}, [] ], label: "active_record_settings")
41
54
  end
42
55
 
43
- def collect(settings, found)
56
+ # A later table setting the reader cannot read hides an earlier literal one, since it runs after it.
57
+ def collect(settings, found, unread)
44
58
  settings.sort_by.with_index { |(entry, _), i| [ entry[:location], i ] }.each do |entry, name|
45
59
  allowed = SETTINGS[name]
46
60
  next unless entry[:assignment] && allowed
47
61
 
48
62
  value = entry[:value]
49
- found[name] = value if allowed.is_a?(Array) ? allowed.include?(value) : value.is_a?(allowed)
63
+ if allowed.is_a?(Array) ? allowed.include?(value) : value.is_a?(allowed) && entry[:literal]
64
+ found[name] = value
65
+ unread.delete(name)
66
+ elsif TableName::TABLE_SETTINGS.include?(name)
67
+ found.delete(name)
68
+ unread << name
69
+ end
50
70
  end
51
71
  end
52
72
 
53
- private_class_method :read, :collect
73
+ private_class_method :read_once, :read, :collect
54
74
  end
55
75
  end
56
76
  end
@@ -822,14 +822,17 @@ module RailsAiContext
822
822
  parent_class: inherited_from(walked, outside&.name) }.reject { |_k, v| v.nil? || v == [] }
823
823
  end
824
824
 
825
- # The parent past the app's own mailers that a mailer with no action of its own takes them from. None past the app
826
- # base, whose methods and everything above it are no actions, and none from Rails itself.
827
- def inherited_from(walked, outside)
828
- return nil if walked.any? { |name| ActionResolver.app_base_name?(name, kind: :mailer) }
825
+ # The parent past the app's own mailers that a mailer with no action of its own takes its actions from. None past
826
+ # the app base, whose methods and everything above it are no actions, and none from Rails' own base.
827
+ def inherited_from(walked, parent_name)
828
+ return nil if walked.any? { |name| app_mailer_base?(name) }
829
829
 
830
- name = outside.to_s.delete_prefix("::")
831
- name unless name.empty? || name.start_with?("ActionMailer::", "AbstractController::") ||
832
- ActionResolver.app_base_name?(name, kind: :mailer)
830
+ name = parent_name.to_s.delete_prefix("::")
831
+ name unless name.empty? || name.start_with?("ActionMailer::", "AbstractController::") || app_mailer_base?(name)
832
+ end
833
+
834
+ def app_mailer_base?(name)
835
+ ActionResolver.app_base_name?(name, kind: :mailer)
833
836
  end
834
837
 
835
838
  def callable_class_methods(names)
@@ -1191,7 +1194,7 @@ module RailsAiContext
1191
1194
  # gives it; tagged STATIC, since a gem's base or a mixin can add actions only the booted tier sees.
1192
1195
  def mailer_entry(klass, ancestors)
1193
1196
  # The app base's public methods are no actions, as the booted tier subtracts them; its callbacks still filter.
1194
- action_chain = [ klass, *ancestors.take_while { |declaring| !ActionResolver.app_base_name?(declaring.name, kind: :mailer) } ]
1197
+ action_chain = [ klass, *ancestors.take_while { |declaring| !app_mailer_base?(declaring.name) } ]
1195
1198
  visibility = {}
1196
1199
  action_chain.each do |declaring|
1197
1200
  own = ActionResolver.own_methods(declaring.methods, declaring.name).select { |m| m[:scope] == :instance }
@@ -107,6 +107,8 @@ module RailsAiContext
107
107
  open_aasm(node) if node.block || node.arguments
108
108
  elsif GEM_MACROS.include?(node.name)
109
109
  record_gem_macro(node)
110
+ elsif node.name == :establish_connection
111
+ @results << { macro: :establish_connection, location: node.location.start_line }
110
112
  elsif node.name == :connects_to
111
113
  # Under a condition the app may never call it, so no table is routed to its database.
112
114
  condition = current_condition
@@ -111,7 +111,7 @@ module RailsAiContext
111
111
  # the rescue meant to contain the first.
112
112
  outside = outside_parent(class_name, candidates)
113
113
  table = resolve_table_name(class_name, candidates, outside: outside)
114
- table_set_by = outside unless table
114
+ table_set_by = table ? unread_table_setting(class_name, candidates) : outside
115
115
  result[class_name] = static_model_details(candidate[:path], class_name, file: candidate[:file],
116
116
  table_name: table, table_set_by: table_set_by,
117
117
  primary_key: resolve_primary_key(class_name, candidates),
@@ -227,7 +227,8 @@ module RailsAiContext
227
227
  path_name = SourceScan.concerns_name(record.path_name, source)
228
228
  declarations = DeclaredConstant.declarations(source, path_name: path_name)
229
229
  declaration = DeclaredConstant.declaration_for(declarations, path_name)
230
- class_name = declaration&.name || path_name
230
+ # A module file is named as it declares itself: camelize knows none of the app's acronyms.
231
+ class_name = declaration&.name || DeclaredConstant.resolve(source, path_name)
231
232
  # Two files can declare one class (an app reopening a model to add methods);
232
233
  # the one that names a superclass defines the model.
233
234
  previous = found[class_name]
@@ -535,6 +536,48 @@ module RailsAiContext
535
536
  candidates.dig(class_name, key) || ActiveRecordSettings.for(app.root)[key] || ""
536
537
  end
537
538
 
539
+ # The setting that shapes this class's table with a value only the running app computes, such as
540
+ # "SDG.table_name_prefix", read in the order resolve_table_name reads them; nil when each was read.
541
+ def unread_table_setting(class_name, candidates, seen = [])
542
+ return nil if seen.include?(class_name) || candidates.dig(class_name, :table_name)
543
+ return "#{class_name}.table_name" if unread_setting?(candidates, class_name, :table_name)
544
+
545
+ parent = sti_parent(class_name, candidates, seen)
546
+ inherited = parent && resolve_table_name(parent, candidates, seen + [ class_name ])
547
+ return unread_table_setting(parent, candidates, seen + [ class_name ]) unless inherited.nil? || inherited.empty?
548
+
549
+ unread_affix(class_name, candidates, :table_name_prefix) || unread_affix(class_name, candidates, :table_name_suffix) ||
550
+ unread_class_setting(class_name, candidates, :pluralize_table_names)
551
+ end
552
+
553
+ def unread_affix(class_name, candidates, key)
554
+ scope = class_name.split("::")[0..-2]
555
+ while scope.any?
556
+ namespace = scope.join("::")
557
+ return "#{namespace}.#{key}" if unread_setting?(candidates, namespace, key)
558
+ return nil if candidates.dig(namespace, key) || (key == :table_name_prefix && isolated_prefixes[namespace])
559
+
560
+ scope.pop
561
+ end
562
+ unread_class_setting(class_name, candidates, key)
563
+ end
564
+
565
+ # A class attribute: the class's own, else its superclass chain's, else the app's.
566
+ def unread_class_setting(class_name, candidates, key, seen = [])
567
+ return "#{class_name}.#{key}" if unread_setting?(candidates, class_name, key)
568
+ return nil unless candidates.dig(class_name, key).nil?
569
+
570
+ parent = candidates.dig(class_name, :superclass)
571
+ resolved = parent && !seen.include?(class_name) && resolve_superclass(parent, class_name, candidates)
572
+ return unread_class_setting(resolved, candidates, key, seen + [ class_name ]) if resolved && candidates.key?(resolved)
573
+
574
+ "ActiveRecord::Base.#{key}" if ActiveRecordSettings.unread(app.root).include?(key)
575
+ end
576
+
577
+ def unread_setting?(candidates, scope, key)
578
+ Array(candidates.dig(scope, :unread_settings)).include?(key)
579
+ end
580
+
538
581
  def isolated_prefixes
539
582
  @isolated_prefixes ||= TableName.namespace_prefixes(app.root)
540
583
  end
@@ -722,7 +765,8 @@ module RailsAiContext
722
765
  detailed = extract_detailed_macros_from_ast(source_data)
723
766
  details.merge!(detailed)
724
767
  details[:ignored_columns] = model.ignored_columns.map(&:to_s).presence
725
- details[:own_connection] = true if own_connection?(model, details)
768
+ # Reflection answers here, over an `establish_connection` the source walk read.
769
+ details[:own_connection] = (true if own_connection?(model, details))
726
770
 
727
771
  details.compact
728
772
  end
@@ -1357,6 +1401,8 @@ module RailsAiContext
1357
1401
  (macros[:model_settings] ||= {})[m[:setting]] = m[:value]
1358
1402
  elsif macro == :connects_to
1359
1403
  macros[:database] = { connects_to: m[:text], condition: m[:condition], declared_in: m[:declared_in], writing: m[:writing] }.compact
1404
+ elsif macro == :establish_connection
1405
+ macros[:own_connection] = true
1360
1406
  elsif macro == :gem_macro
1361
1407
  (macros[:gem_macros] ||= []) << m.slice(:text, :adds)
1362
1408
  elsif macro == :aasm
@@ -60,11 +60,11 @@ module RailsAiContext
60
60
 
61
61
  # { tables:, foreign_keys: } from the database's dump, or { reason: } saying why there is none to read.
62
62
  def read_dump(db)
63
- return { reason: no_dump_reason(db) } unless db.dump
63
+ reason = db.missing_dump(root)
64
+ return { reason: reason } if reason
64
65
 
65
66
  path = db.dump.path
66
67
  shown = path.delete_prefix("#{root.to_s.chomp('/')}/")
67
- return { reason: "#{shown} is not on disk" } unless File.exist?(path)
68
68
 
69
69
  content = SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
70
70
  return { reason: "#{shown} is over max_schema_file_size" } unless content
@@ -75,13 +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
- def no_dump_reason(db)
79
- return "its database.yml entry turns `database_tasks` off, so Rails writes it no dump" unless db.tasks
80
- return "its database.yml entry turns `schema_dump` off, so Rails writes it no dump" unless db.settings["schema_dump"]
81
-
82
- "`schema_dump` in its database.yml entry names no file this check reads"
83
- end
84
-
85
78
  # The models section when the run has one; otherwise the model tier is
86
79
  # asked directly, which is what a standalone call costs.
87
80
  def resolved_models
@@ -589,7 +589,8 @@ module RailsAiContext
589
589
  return { unavailable: "this app uses Mongoid; ActiveRecord schema introspection does not apply" }
590
590
  end
591
591
 
592
- if secondary_database_dumps.any?
592
+ # A database with no file on disk says nothing of where the app keeps its tables.
593
+ if secondary_database_dumps.any? { |_, db| db.key?(:dump) || db.key?(:migrations_paths) }
593
594
  return { total_tables: 0, tables: {}, note: "The primary database has no tables yet: no #{relative_dump_path(schema_dump_name)} or migrations found." }
594
595
  end
595
596
 
@@ -636,6 +637,7 @@ module RailsAiContext
636
637
  found[name] = parsed.merge(database_files(path, migrate_dir_for_dump(path)))
637
638
  end
638
639
  replay_secondary_migrations(dumps, unread)
640
+ add_databases_with_nothing_to_read(dumps)
639
641
  unread.each do |name, (path, state)|
640
642
  dumps[name] ||= { tables: {}, total_tables: 0, note: "#{relative_dump_path(path)} #{state}, so its tables are not listed" }
641
643
  .merge(database_files(path, migrate_dir_for_dump(path)), state == NO_TABLES ? {} : { unread: true })
@@ -644,6 +646,17 @@ module RailsAiContext
644
646
  dumps.sort_by { |name, _| names.index(name) || names.size }.to_h
645
647
  end
646
648
 
649
+ # A database with no dump on disk and no migrations of its own is still one of the app's: its entry says why.
650
+ def add_databases_with_nothing_to_read(dumps)
651
+ primary = dump_candidates.map(&:last)
652
+ app_databases.each do |db|
653
+ next if db.primary || db.replica || dumps.key?(db.name) || primary.include?(db.dump&.path)
654
+
655
+ reason = db.missing_dump(app.root)
656
+ dumps[db.name] = { tables: {}, total_tables: 0, unread: true, note: "Its tables are not listed: #{reason}" } if reason
657
+ end
658
+ end
659
+
647
660
  # Where a secondary database's schema lives, app-relative, so a rule
648
661
  # file can name its dump and the migrations directories it has.
649
662
  def database_files(dump, migrate_dirs)
@@ -19,6 +19,7 @@ module RailsAiContext
19
19
  module_function
20
20
 
21
21
  NONE = { table_name: nil, table_name_prefix: nil, table_name_suffix: nil, pluralize_table_names: nil, primary_key: nil }.freeze
22
+ TABLE_SETTINGS = %i[table_name table_name_prefix table_name_suffix pluralize_table_names].freeze
22
23
 
23
24
  # {"RssPolling" => "rss_polling_"}: the engine prefix no model file says.
24
25
  # ponytail: reads `lib/**/engine.rb` only; walk lib wholesale if an app needs more.
@@ -38,14 +39,17 @@ module RailsAiContext
38
39
  #
39
40
  # @param source [String] the file's source
40
41
  # @param name [String] the qualified name of the class or module
41
- # @return [Hash] the five, each nil when this scope declares none
42
+ # @return [Hash] the five, each nil when this scope declares none, and under `unread_settings`
43
+ # the table settings it declares with a value only the running app computes
42
44
  # @param root [String, nil] the app, whose configured affixes a table
43
45
  # name interpolating table_name_prefix or table_name_suffix reads
44
46
  def declarations(source, name, root = nil)
45
47
  read(source, name) do |body|
46
48
  own = { table_name_prefix: affix(body, :table_name_prefix), table_name_suffix: affix(body, :table_name_suffix) }
47
- { table_name: assigned(body, :table_name=) || interpolated(body, own, root) }.merge(own)
48
- .merge(pluralize_table_names: boolean_assigned(body, :pluralize_table_names=), primary_key: primary_key_assigned(body))
49
+ read = { table_name: assigned(body, :table_name=) || interpolated(body, own, root) }.merge(own)
50
+ .merge(pluralize_table_names: boolean_assigned(body, :pluralize_table_names=), primary_key: primary_key_assigned(body))
51
+ unread = TABLE_SETTINGS.select { |setting| read[setting].nil? && declares?(body, setting) }
52
+ unread.any? ? read.merge(unread_settings: unread) : read
49
53
  end || NONE
50
54
  end
51
55
 
@@ -227,6 +231,13 @@ module RailsAiContext
227
231
  names if names.all?
228
232
  end
229
233
 
234
+ def declares?(body, setting)
235
+ body.any? do |node|
236
+ ((node.is_a?(Prism::CallNode) && node.name == :"#{setting}=") || (node.is_a?(Prism::DefNode) && node.name == setting)) &&
237
+ node.receiver.is_a?(Prism::SelfNode)
238
+ end
239
+ end
240
+
230
241
  def boolean_assigned(body, name)
231
242
  call = body.find do |node|
232
243
  node.is_a?(Prism::CallNode) && node.name == name && node.receiver.is_a?(Prism::SelfNode)
@@ -286,7 +297,7 @@ module RailsAiContext
286
297
  end
287
298
 
288
299
  private_class_method :affix, :read, :body_of, :built_body, :descend, :statements, :segment,
289
- :assigned, :boolean_assigned, :primary_key_assigned, :returned, :literal, :read_namespace_prefixes,
300
+ :assigned, :declares?, :boolean_assigned, :primary_key_assigned, :returned, :literal, :read_namespace_prefixes,
290
301
  :interpolated, :affixed_node, :affix_read,
291
302
  :engine_files, :collect_isolate_calls
292
303
  end
@@ -217,11 +217,16 @@ module RailsAiContext
217
217
  return [] unless model.is_a?(Hash) && model[:table_name]
218
218
 
219
219
  found = table_holders(schema, model[:table_name]).map { |db, _, data| [ db || "primary", data ] }
220
- writing = model.dig(:database, :writing)&.to_s || ("primary" unless model[:database])
220
+ writing = writing_database(model)
221
221
  written = found.select { |db, _| db == writing }.first(1)
222
222
  written.any? ? written : found
223
223
  end
224
224
 
225
+ # The database a model writes to, "primary" with no connects_to, nil where the static tier cannot tell.
226
+ def writing_database(model)
227
+ model.dig(:database, :writing)&.to_s || ("primary" unless model[:database])
228
+ end
229
+
225
230
  def model_databases(schema, model)
226
231
  model_tables(schema, model).map(&:first)
227
232
  end
@@ -261,6 +266,18 @@ module RailsAiContext
261
266
  model_tables(schema, model).empty?
262
267
  end
263
268
 
269
+ # The primary's schema.rb, such as "db/schema.rb", when the static tier read its tables from it and it leaves out
270
+ # the table of a model on the primary. Nil otherwise: no other dump or replay is judged.
271
+ def table_absent_from(schema, model)
272
+ return unless model.is_a?(Hash) && model[:table_name] && !model[:table_name].to_s.include?(".") && !model[:own_connection]
273
+ return unless writing_database(model) == "primary"
274
+ # A setting, concern or base the static tier could not read may set the table name.
275
+ return if model[:table_set_by] || Array(model[:concerns_unread]).any? || Array(model[:bases_unread]).any?
276
+ return unless schema.is_a?(Hash) && parsed_schema?(schema) && model_tables(schema, model).empty?
277
+
278
+ schema[:declared_in]
279
+ end
280
+
264
281
  # Why the primary's dump leaves out a schema-qualified table, or nil.
265
282
  def missing_qualified_table(schema, name)
266
283
  return unless schema.is_a?(Hash) && parsed_schema?(schema) && name.to_s.include?(".")
@@ -45,8 +45,9 @@ module RailsAiContext
45
45
  lines.join("\n")
46
46
  end
47
47
 
48
- # Every database's tables, the primary's first. With more than one, each
49
- # gets a heading, since a table name can be in two of them. A database
48
+ # Every database's tables, the primary's first. Each gets a heading unless the
49
+ # primary is the only one, since a table name can be in two and a lone other
50
+ # database is not the one a reader assumes. A database
50
51
  # a Rails framework keeps to itself (Rails 8's queue, cache and cable)
51
52
  # is no part of it: its tables are the framework's, CLAUDE.md names it,
52
53
  # and its dump says nothing this listing would help with.
@@ -66,9 +67,10 @@ module RailsAiContext
66
67
  live = " Use #{tool_ref("rails_get_schema", 'table:"name"', "table=name")} for live data." if served?("rails_get_schema")
67
68
  lines << "_Snapshot - may be stale after migrations.#{live}_"
68
69
 
70
+ primary_alone = databases.size == 1 && databases.first.first.nil?
69
71
  databases.each do |name, tables|
70
72
  lines << ""
71
- lines.concat(database_heading_lines(name, tables, secondary)) if secondary.any?
73
+ lines.concat(database_heading_lines(name, tables, secondary)) unless primary_alone
72
74
  lines.concat(table_reference_lines(tables, name, schema))
73
75
  end
74
76
 
@@ -122,10 +122,13 @@ module RailsAiContext
122
122
  buckets
123
123
  end
124
124
 
125
- # The table a model reads when it is not plainly named: missing from a live listing, or set by
126
- # a parent the static tier did not read. Nil otherwise.
125
+ # The table a model reads when it is not plainly named: missing from a live listing or the dump,
126
+ # or set by a parent the static tier did not read. Nil otherwise.
127
127
  def table_note(schema, model_data)
128
128
  return "`#{model_data[:table_name]}` not found" if Payload.missing_table?(schema, model_data)
129
+ if (source = Payload.table_absent_from(schema, model_data))
130
+ return "`#{model_data[:table_name]}` missing from #{source}"
131
+ end
129
132
  return nil if model_data[:table_name] || !model_data[:table_set_by]
130
133
 
131
134
  "set by `#{model_data[:table_set_by]}`, which the static tier did not read"
@@ -128,6 +128,8 @@ module RailsAiContext
128
128
  FROM_LIST_CLAUSES = %w[from join on using].freeze
129
129
  CLAUSE_WORDS = %w[select from join on where group order having limit values using returning set union intersect except].freeze
130
130
 
131
+ # A double-quoted bare word, which MySQL under ANSI_QUOTES reads as a name and otherwise as a string.
132
+ BARE_QUOTED_WORD = /\A"\w+"\z/
131
133
  # How a statement reads a session setting by name: SHOW, @@, @@SESSION. or @@LOCAL. (backquoted or not), current_setting('..').
132
134
  SETTING_READ = /(?:\bshow\s+(?!global\b)|@@(?!global\s*\.)(?:(?:session|local)\s*\.\s*)?|current_setting\s*\(\s*')`?(\w+)/i
133
135
  # A list of the session's settings, which names each in its rows or in the filter the statement puts on it.
@@ -335,9 +337,9 @@ module RailsAiContext
335
337
  def self.settings_read(sql, family, rows)
336
338
  text = comment_free(sql, family)
337
339
  code = mask_quoted(text, family) { true }
338
- # MySQL reads a double-quoted span as a string, or under ANSI_QUOTES as a name, which only a bare word can be here.
340
+ # On MySQL a double-quoted span other than a bare word is a string.
339
341
  strings_blanked = mask_quoted(text, family) do |kind, span|
340
- kind == :string || (family.mysql? && span.start_with?('"') && !span.match?(/\A"\w+"\z/))
342
+ kind == :string || (family.mysql? && span.start_with?('"') && !span.match?(BARE_QUOTED_WORD))
341
343
  end
342
344
  read = text.enum_for(:scan, SETTING_READ).filter_map { $~[1] if code[$~.begin(0)] == text[$~.begin(0)] }
343
345
  read += text.scan(/\w+/) + rows.flatten.map(&:to_s) if strings_blanked.match?(SETTINGS_LIST)
@@ -467,7 +469,7 @@ module RailsAiContext
467
469
  return never_read_refusal(m.captures.compact.first)
468
470
  end
469
471
  if (m = listed(SYSTEM_SCHEMAS, mask_quoted(cleaned, :string)))
470
- text_only = ->(kind, span) { kind == :string || (span.start_with?('"') && !span.match?(/\A"\w+"\z/)) }
472
+ text_only = ->(kind, span) { kind == :string || (span.start_with?('"') && !span.match?(BARE_QUOTED_WORD)) }
471
473
  return "#{never_read_refusal(m[1])} A name before a dot is read as that schema, so an alias named `#{m[1]}` needs another name." if
472
474
  listed(SYSTEM_SCHEMAS, mask_quoted(cleaned, &text_only))
473
475
 
@@ -964,7 +966,7 @@ module RailsAiContext
964
966
  end
965
967
  if (fn = volatile_function(called)) && PG_VOLATILE_ALLOWLIST.include?(fn)
966
968
  "Blocked: the database holds a VOLATILE function named #{fn} that rails_query cannot vouch for, so rails_query runs no " \
967
- "call of that name. Its list of read-only VOLATILE functions trusts the name for the server's own function or an extension's C function alone."
969
+ "call of that name. Its list of read-only VOLATILE functions trusts the name for the server's own function or a C function alone."
968
970
  elsif fn
969
971
  "Blocked: function #{fn} is VOLATILE (pg_proc.provolatile = 'v') and not on " \
970
972
  "rails_query's list of read-only VOLATILE functions, so it is not run. Some VOLATILE " \
@@ -1078,7 +1080,7 @@ module RailsAiContext
1078
1080
  exprs.each_with_object(Set.new) { |expr, names| expr.scan(/([A-Za-z_]\w*)\s*\(/) { |(fn)| names << fn.downcase } }
1079
1081
  end
1080
1082
 
1081
- # A name on the read-only list vouches for the server's own function and C code alone: while the database holds a
1083
+ # A name on the read-only list vouches for the server's own function or a C function alone: while the database holds a
1082
1084
  # VOLATILE function of that name in another language outside pg_catalog, every call of the name is refused.
1083
1085
  def volatile_function(called)
1084
1086
  return nil if called.empty?
@@ -578,7 +578,7 @@ module RailsAiContext
578
578
  # Only a controller the app has: the conventional name is a guess, and
579
579
  # a hint naming a class that does not exist sends the reader nowhere.
580
580
  controller_name = Payload.controller_for_route_key(cached_context, name.underscore.pluralize)&.first
581
- add_next_hint(lines, (pointer("rails_get_schema", %(table:"#{data[:table_name]}"), "for columns/indexes") if data[:table_name]),
581
+ add_next_hint(lines, (pointer("rails_get_schema", %(table:"#{data[:table_name]}"), "for columns/indexes") if data[:table_name] && !note),
582
582
  (pointer("rails_get_controllers", %(controller:"#{controller_name}"), "for actions") if controller_name),
583
583
  pointer("rails_analyze_feature", %(feature:"#{name}"), "for full-stack view"))
584
584
 
@@ -91,6 +91,9 @@ module RailsAiContext
91
91
  usage = "Pass `tool:params`, e.g. `get_schema:users`."
92
92
  return "`mark` names no tool. #{usage}" if name.to_s.empty?
93
93
 
94
+ skipped = RailsAiContext::CLI::ToolRunner.turned_off_message(name)
95
+ return skipped if skipped
96
+
94
97
  names = RailsAiContext::Server.exposed_tools.map(&:tool_name) | RailsAiContext::Server.retired_names
95
98
  short_names = names.map { |tool_name| RailsAiContext::CLI::ToolRunner.short_name(tool_name) }
96
99
  suggestion = find_closest_match(name, short_names)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.36.0"
4
+ VERSION = "5.36.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.36.0
4
+ version: 5.36.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine