rails-ai-context 5.36.1 → 5.36.3

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/docs/COMPATIBILITY.md +13 -0
  4. data/docs/INTROSPECTORS.md +3 -3
  5. data/docs/TOOLS.md +20 -17
  6. data/lib/rails_ai_context/app_databases.rb +15 -0
  7. data/lib/rails_ai_context/cli/tool_runner.rb +8 -2
  8. data/lib/rails_ai_context/introspectors/active_record_settings.rb +31 -11
  9. data/lib/rails_ai_context/introspectors/convention_introspector.rb +10 -1
  10. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +2 -0
  11. data/lib/rails_ai_context/introspectors/migration_introspector.rb +26 -1
  12. data/lib/rails_ai_context/introspectors/model_introspector.rb +49 -3
  13. data/lib/rails_ai_context/introspectors/performance_introspector.rb +2 -9
  14. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +13 -9
  15. data/lib/rails_ai_context/introspectors/schema_introspector.rb +14 -1
  16. data/lib/rails_ai_context/introspectors/table_name.rb +15 -4
  17. data/lib/rails_ai_context/introspectors/test_introspector.rb +14 -5
  18. data/lib/rails_ai_context/payload.rb +18 -1
  19. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +5 -3
  20. data/lib/rails_ai_context/serializers/section_facts.rb +5 -2
  21. data/lib/rails_ai_context/tools/analyze_feature.rb +37 -25
  22. data/lib/rails_ai_context/tools/diagnose.rb +14 -8
  23. data/lib/rails_ai_context/tools/generate_test.rb +1 -1
  24. data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
  25. data/lib/rails_ai_context/tools/migration_advisor.rb +26 -3
  26. data/lib/rails_ai_context/tools/review_changes.rb +8 -2
  27. data/lib/rails_ai_context/tools/search_code.rb +9 -2
  28. data/lib/rails_ai_context/tools/session_context.rb +3 -0
  29. data/lib/rails_ai_context/version.rb +1 -1
  30. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ae3eb5f18027a0abe6cf4a8872f9ecce9fb792109ab243b9b7edbba3558cb6d
4
- data.tar.gz: c6c7b740f4e18e494a371215c99c05bad848ed43ebf1f84f24020ad4348612ac
3
+ metadata.gz: ecc8cfb0b751983a682c2a2c0bd2082216595b83f12f679d190506425d60ae48
4
+ data.tar.gz: e0674fed2e5f40ebe3f33166c59d9c17bcd5c5160352100cf8b38d87a73387a4
5
5
  SHA512:
6
- metadata.gz: fe22c7af7f32d7aa16abdc3b6534653614f3e38fc25a1406c5e29ab9dbb0cf609878fd633210cb3006352007b156d5eaba887db3d1e9ad53cec0ae57357622c0
7
- data.tar.gz: 6b0aff79f555f43322eab5c3c81ebcc8a7477a8674891d1f3d2988a47cd23044db5383aacbbd6db3ec15cdeb882dc37b5af4e9cb98c73f1150ba8c73d55cf283
6
+ metadata.gz: c78d50c7e050632c88f60e901a8805ed5d397f2f12fb9412fa7ac64af1ed3921ff776486a8e4912f0a5a531e6a90661ca6d7b35def872bc4cd11215bde2941b0
7
+ data.tar.gz: 0b597f6cc7372007cb84936ac62f2ca893791622c62a667ea4601b545c73a9719949f3abfc09fd8fe75fe2e143fb1bfa5a4bbf82ee47e4f5fca6d293392c5b9d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,100 @@ 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.3] - 2026-10-15
9
+
10
+ ### Fixed
11
+
12
+ - **`rails_diagnose` names an instance variable receiver with its `@`, and takes it from the line the error names.**
13
+ `@widget.name` read as `widget`, so the advice named a variable the action does
14
+ not have. The receiver also came from the first call of the method in the file,
15
+ not from the line. A line that does not call the method on a plain name now
16
+ gets no receiver instead of another line's. 5.36.2 and earlier versions had the
17
+ gap.
18
+ - **`rails_generate_test` says which sign-in helper names it looked for.** The
19
+ TODO read "this app's tests define no sign-in helper" for a suite that signs in
20
+ through a helper with another name. It now reads "no `sign_in` or `log_in`
21
+ helper in this app's test helpers".
22
+ - **A `change_table` migration lists what its block does.** `rails://migrations`
23
+ showed `"actions": []` for a migration that adds columns through
24
+ `change_table`, and `migration_stats` left it out of `total_add_column`. The
25
+ table calls in the block (`t.string`, `t.references`, `t.index`, `t.remove` and
26
+ the others) are read from the AST. A `create_table` block's columns are not
27
+ counted.
28
+ - **`rails_search_code` trace says how many sibling methods and internal calls it
29
+ left out.** It listed 10 siblings and 15 internal calls and stopped. The list
30
+ now ends with "_...and N more_".
31
+ - **`rails_get_job_pattern` reads sidekiq-scheduler entries from every
32
+ `config/sidekiq*.yml`.** A schedule kept in `config/sidekiq_production.yml`,
33
+ one file per environment, showed no `Schedule`, so a worker that runs every
34
+ night read like dead code. Each file's entry is shown with its file name.
35
+ - **`rails_get_config(section:"conventions")` counts the rake tasks of
36
+ `lib/tasks` and the templates of `app/views`.** The directory counts took
37
+ `.rb` files only: `lib/tasks` read 2 files for 67, and `app/views` never
38
+ appeared.
39
+ - **`rails_get_test_info` counts only `*_spec.rb` under `spec/` in an RSpec
40
+ suite.** A `*_test.rb` helper in `spec/support` was counted as a test file. A
41
+ Minitest suite still counts both `*_spec.rb` and `*_test.rb`.
42
+ - **`rails_migration_advisor` writes a reference and a new table with the key
43
+ type the app uses.** On a schema of `id: :uuid` tables, `add_reference` made a
44
+ bigint column that PostgreSQL refuses to key to a uuid, and `create_table`
45
+ made the one bigint table. `add_reference` and `t.references` take the key type
46
+ of the table they point at (the app's type when the schema lacks that table),
47
+ and `create_table` takes the type most of the app's tables share.
48
+ - **`rails_review_changes` matches `files` by whole path or directory.**
49
+ `files: ["Gemfile"]` also returned `Gemfile.lock`. A directory such as
50
+ `app/models` still matches the files under it.
51
+ - **`rails_analyze_feature` says which directories it stopped reading.** It reads
52
+ the first 500 files of a directory, and a service, view or test past that was
53
+ missed with no word. The answer now ends with the directories it did not
54
+ finish. The "first 500 scanned" note no longer shows when exactly 500 files were
55
+ read.
56
+
57
+ ## [5.36.2] - 2026-10-15
58
+
59
+ ### Fixed
60
+
61
+ - **`rails_onboard` and `rails_get_schema` name every database
62
+ `rails_performance_check` names.** A database database.yml configures with no
63
+ dump on disk and no migrations of its own, or with `database_tasks: false` and
64
+ no committed dump, was left out of both. `rails_onboard` now lists it as
65
+ "(tables not read)", and `rails_get_schema` says why: "Its tables are not
66
+ listed: db/extra_schema.rb is not on disk". A replica stays out of all three,
67
+ and so does a database whose dump is the primary's. The schema rules file
68
+ gives the primary no heading of its own when it is the only database with
69
+ tables. For a `schema_dump` the gem cannot follow, the reason now reads
70
+ "`schema_dump` in its database.yml entry names no file to read" on
71
+ `rails_get_schema` and `rails_performance_check`. 5.36.1 and earlier versions
72
+ had the gap.
73
+ - **On the static tier a model of the primary database whose table db/schema.rb
74
+ does not declare says so.** `rails_get_model_details`, `rails_onboard` and the
75
+ generated files named the conventional table, `widgets`, with no word that
76
+ db/schema.rb holds no such table, and model details pointed to
77
+ `rails_get_schema` for it. They now answer "`widgets` missing from
78
+ db/schema.rb", and model details leaves out the pointer for a table it says is
79
+ missing. A model whose table or database the static tier cannot be sure of is
80
+ left unjudged: one of another database, one on a connection of its own
81
+ (`establish_connection` in its class or a base), one whose table name a
82
+ computed `table_name`, affix or `pluralize_table_names` shapes, one with a
83
+ concern or base the static tier could not read, a table in another schema, and
84
+ tables read from db/structure.sql or the migrations, since a replay can miss a
85
+ table raw SQL creates. 5.36.1 and earlier versions had the gap.
86
+ - **The static tier reads the table name prefix of a module whose name camelize
87
+ does not give.** A module file such as app/models/sdg.rb that declares
88
+ `module SDG` with `table_name_prefix` was keyed as `Sdg`, so `SDG::Goal` got
89
+ the table `goals` where Rails reads `sdg_goals`. The module is now named as it
90
+ declares itself. 5.36.1 and earlier versions had the gap.
91
+ - **The static tier builds no table name from an affix it could not read.**
92
+ `ActiveRecord::Base.table_name_prefix = ENV.fetch("P", "")` gave every model a
93
+ table such as `[INFERRED]widgets`. An affix or `pluralize_table_names` the app
94
+ computes is now left out of the guessed name, and a later computed assignment
95
+ hides an earlier literal one, as it does when the app runs. 5.36.1 and earlier
96
+ versions had the gap.
97
+ - **`rails_session_context(mark:)` says a tool skip_tools turned off is turned
98
+ off.** It answered "Unknown tool" for a tool the gem has, where a call of the
99
+ tool says it is turned off. Both now give the same answer, by the tool's full
100
+ or short name. 5.36.1 and earlier versions had the gap.
101
+
8
102
  ## [5.36.1] - 2026-10-15
9
103
 
10
104
  ### Fixed
@@ -327,6 +327,19 @@ Proof sources:
327
327
  published code on PostgreSQL 13 to 18, MySQL 8.0 and 8.4 and MariaDB 10.6
328
328
  and 11.4, the booted and static mailer lists compared on one app, and the
329
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.
336
+ In the v5.36.3 release QA: each fix's example failing on the v5.36.2 code;
337
+ the issues' repros run through the CLI on a minimal app, and on copies of two
338
+ large apps (both in the static tier, one also booted) against counts taken
339
+ from their files; the introspector, tool and integration specs on Ruby 3.1
340
+ with Rails 7.0 and 7.2, Ruby 3.3 with Rails 7.1, Ruby 3.4 with Rails 8.0 and
341
+ 8.1, and Ruby 4.0 with Rails 8.1; and the unit suite and the E2E harness on
342
+ Linux, macOS and Windows.
330
343
  2. Non-crash coverage for every built-in tool including `get_view` in
331
344
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
332
345
  (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 |
@@ -285,7 +285,7 @@ same wherever it is asked. Those live as their own modules under
285
285
  | `ModuleAliases` | Which app file a bare JS import specifier names: tsconfig/jsconfig `compilerOptions.paths` followed through `extends` (relative files and installed packages), and a vite/webpack/rspack `resolve.alias` written as a literal object. The Stimulus scan uses it to tie a registration or a base class to the controller file it imports |
286
286
  | `HelperNames` | The helper methods a view can call: every method the app's helper modules define in every code root, and those of a module they `include`, found through the app's autoload roots (`lib` among them) or in its enclosing namespace's file (`CanonicalURL::Helpers` in `canonical_url.rb`). `rails_get_partial_interface` uses it so a helper call is not read as a local |
287
287
  | `Interaction` | Whether a class runs as an ActiveInteraction, following its superclass chain through the app's own sources, and the filters it takes - inherited ones first, one per name, each carrying the filters nested inside its block. See the **Interaction filter** entry in `CONTEXT.md` |
288
- | `RecurringSchedules` | The recurring tasks each scheduler reads from its own file: Solid Queue's `config/recurring.yml`, sidekiq-cron's `config/schedule.yml`, sidekiq-scheduler's section of `config/sidekiq.yml`, GoodJob's `config.good_job.cron` and whenever's `config/schedule.rb`. The job introspector hands it its own config walk, so a file it also reads for queue or mailer settings is walked once |
288
+ | `RecurringSchedules` | The recurring tasks each scheduler reads from its own file: Solid Queue's `config/recurring.yml`, sidekiq-cron's `config/schedule.yml`, sidekiq-scheduler's section of each `config/sidekiq*.yml`, GoodJob's `config.good_job.cron` and whenever's `config/schedule.rb`. The job introspector hands it its own config walk, so a file it also reads for queue or mailer settings is walked once |
289
289
  | `AdminResources` | The models an admin gem exposes (ActiveAdmin, Trestle, Administrate, Avo, Madmin), from the folder each gem's generator writes to, with ActiveAdmin's `permit_params`. `rails_get_config(section:"conventions")` and `rails_analyze_feature` both ask it |
290
290
  | `SchemaDumpPath` | The primary database's dump file as `DatabaseTasks.schema_dump_path` names it: `database.yml`'s `schema_dump` in the configured `schema_format`, then each format's default file, and the secondary databases' dumps. Every schema reader, the doctor and the pending-migration check ask it |
291
291
  | `ApartmentConfig` | The models an Apartment initializer keeps in the shared schema (`excluded_models`), or the source of a list the file computes. The model introspector asks it to say which models get one table per tenant |
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 |
@@ -823,7 +826,7 @@ Each diff passes through the redaction every tool's source does, read as the fil
823
826
  | Parameter | Type | Default | Description |
824
827
  |:----------|:-----|:--------|:------------|
825
828
  | `ref` | string | `HEAD` | Git ref to diff against: a branch, a tag, `HEAD~3` or a commit SHA. `HEAD` reviews uncommitted changes. Any other ref reviews the commits since HEAD left it, as a pull request does: the file list and each file's diff run from the commit the two last shared to HEAD. A ref that names no commit is refused |
826
- | `files` | array | - | Review only these changed files (omit for all) |
829
+ | `files` | array | - | Review only these changed files, or the changed files under these directories (omit for all). A path matches whole, so `Gemfile` is not `Gemfile.lock` |
827
830
 
828
831
  ### `rails_runtime_info`
829
832
 
@@ -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
@@ -172,6 +172,14 @@ module RailsAiContext
172
172
  RailsAiContext.debug_fail(e, false, label: "uses_async_queries?")
173
173
  end
174
174
 
175
+ # Files a directory holds besides Ruby, which SourceScan leaves out.
176
+ NON_RUBY_FILES = {
177
+ "app/javascript" => "**/*.js",
178
+ "app/javascript/controllers" => "**/*.js",
179
+ "app/views" => "**/*.{erb,haml,slim,jbuilder,builder}",
180
+ "lib/tasks" => "**/*.rake"
181
+ }.freeze
182
+
175
183
  def scan_directory_structure
176
184
  important_dirs = %w[
177
185
  app/models app/controllers app/views app/jobs
@@ -190,7 +198,8 @@ module RailsAiContext
190
198
  # carries file counts, so the biggest code area had no number anywhere.
191
199
  (important_dirs | app_child_dirs).each_with_object({}) do |dir, hash|
192
200
  count = SourceScan.paths(root, kind: dir, skip_concerns: false).count
193
- count += Dir.glob(File.join(root, dir, "**/*.js")).size if dir.include?("javascript")
201
+ glob = NON_RUBY_FILES[dir]
202
+ count += PathResolver.dirs_for(root, dir).sum { |path| Dir.glob(File.join(path, glob)).size } if glob
194
203
 
195
204
  hash[dir] = count if count > 0
196
205
  end
@@ -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
@@ -121,7 +121,32 @@ module RailsAiContext
121
121
  add_foreign_key remove_foreign_key
122
122
  add_timestamps create_join_table enable_extension execute
123
123
  add_check_constraint remove_check_constraint
124
- ].select { |action| content.include?(action) }
124
+ ].select { |action| content.include?(action) } | change_table_actions(content)
125
+ end
126
+
127
+ # What each table call in a change_table block does; a column type adds a column, any other call does nothing.
128
+ CHANGE_TABLE_CALLS = {
129
+ index: "add_index", remove_index: "remove_index", remove: "remove_column", remove_timestamps: "remove_column",
130
+ remove_references: "remove_reference", remove_belongs_to: "remove_reference", references: "add_reference",
131
+ belongs_to: "add_reference", timestamps: "add_timestamps", rename: "rename_column", change: "change_column",
132
+ change_default: "change_column_default", change_null: "change_column_null", foreign_key: "add_foreign_key",
133
+ remove_foreign_key: "remove_foreign_key", check_constraint: "add_check_constraint",
134
+ remove_check_constraint: "remove_check_constraint"
135
+ }.freeze
136
+ COLUMN_CALLS = (Listeners::SchemaDslListener::COLUMN_TYPES.map(&:to_sym) + [ :column ] - CHANGE_TABLE_CALLS.keys).freeze
137
+
138
+ # A table call counts only inside a change_table block: the listener links each call to the block that encloses it.
139
+ def change_table_actions(content)
140
+ return [] unless content.include?("change_table")
141
+
142
+ hits = SourceIntrospector.walk_source(content, {
143
+ change_table: -> { Listeners::GenericMacroListener.new(:change_table, *CHANGE_TABLE_CALLS.keys, *COLUMN_CALLS, any_receiver: true) }
144
+ })[:change_table]
145
+ blocks = hits.select { |hit| hit[:macro] == :change_table }.to_set { |hit| hit[:offset] }
146
+ return [] if blocks.empty?
147
+
148
+ inside = hits.select { |hit| blocks.include?(hit[:parent_offset]) }
149
+ [ "change_table" ] | inside.map { |hit| CHANGE_TABLE_CALLS.fetch(hit[:macro], "add_column") }
125
150
  end
126
151
  end
127
152
  end
@@ -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
@@ -26,24 +26,28 @@ module RailsAiContext
26
26
  end
27
27
  end
28
28
 
29
+ SIDEKIQ_CRON_FILES = %w[config/schedule.yml config/schedule.yaml config/sidekiq_cron.yml].freeze
30
+
29
31
  def sidekiq_cron(root)
30
- %w[config/schedule.yml config/schedule.yaml config/sidekiq_cron.yml].flat_map do |file|
32
+ SIDEKIQ_CRON_FILES.flat_map do |file|
31
33
  named_tasks(yaml(root, file)).filter_map { |name, options| task(file, name, options, :cron) }
32
34
  end
33
35
  end
34
36
 
35
37
  # sidekiq-scheduler runs a job by its entry's name when no class is given.
38
+ # Sidekiq starts from any file `-C` names, and apps keep one per environment.
36
39
  def sidekiq_scheduler(root)
37
- file = "config/sidekiq.yml"
38
- data = yaml(root, file)
39
- return [] unless data.is_a?(Hash)
40
+ (Dir.glob("config/sidekiq*.{yml,yaml}", base: root.to_s).sort - SIDEKIQ_CRON_FILES).flat_map do |file|
41
+ data = yaml(root, file)
42
+ next [] unless data.is_a?(Hash)
40
43
 
41
- section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
42
- named_tasks(section).filter_map do |name, options|
43
- next unless options.is_a?(Hash)
44
+ section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
45
+ named_tasks(section).filter_map do |name, options|
46
+ next unless options.is_a?(Hash)
44
47
 
45
- kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
46
- task(file, name, { "class" => name }.merge(options), kind)
48
+ kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
49
+ task(file, name, { "class" => name }.merge(options), kind)
50
+ end
47
51
  end
48
52
  end
49
53
 
@@ -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
@@ -10,8 +10,6 @@ module RailsAiContext
10
10
  extend StaticTier
11
11
  static_tier :files_only
12
12
 
13
- TEST_FILE_GLOB = "*_{spec,test}.rb"
14
-
15
13
  # An engine's test/dummy is tested by the engine's suite.
16
14
  def suite_root
17
15
  @suite_root ||= PathResolver.test_root(root)
@@ -56,6 +54,17 @@ module RailsAiContext
56
54
  RailsAiContext.debug_fail(e, "unknown", label: "detect_framework")
57
55
  end
58
56
 
57
+ # RSpec runs *_spec.rb only, so a *_test.rb under spec/ is a helper.
58
+ def test_file_glob(base)
59
+ base == "spec" && rspec_suite? ? "*_spec.rb" : "*_{spec,test}.rb"
60
+ end
61
+
62
+ def rspec_suite?
63
+ return @rspec_suite if defined?(@rspec_suite)
64
+
65
+ @rspec_suite = RailsAiContext::TestFramework.suites(suite_root).include?("rspec")
66
+ end
67
+
59
68
  # First listed wins: an app holding the same thing under spec/ and test/
60
69
  # reports one location, not both. `detect_system_tests` deliberately
61
70
  # answers the other way.
@@ -247,7 +256,7 @@ module RailsAiContext
247
256
  dir = File.join(suite_root, rel)
248
257
  next unless Dir.exist?(dir)
249
258
 
250
- count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
259
+ count = Dir.glob(File.join(dir, "**/#{test_file_glob(rel.split("/").first)}")).size
251
260
  [ rel, count ] if count > 0
252
261
  end
253
262
  return nil if rows.empty?
@@ -479,11 +488,11 @@ module RailsAiContext
479
488
  dir = File.join(base_dir, entry)
480
489
  next unless File.directory?(dir)
481
490
 
482
- count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
491
+ count = Dir.glob(File.join(dir, "**/#{test_file_glob(base)}")).size
483
492
  rows[entry] << [ "#{base}/#{entry}", count ] if count > 0
484
493
  end
485
494
 
486
- loose = Dir.glob(File.join(base_dir, TEST_FILE_GLOB)).size
495
+ loose = Dir.glob(File.join(base_dir, test_file_glob(base))).size
487
496
  rows[base] << [ base, loose ] if loose > 0
488
497
  end
489
498
 
@@ -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"
@@ -62,23 +62,24 @@ module RailsAiContext
62
62
  pattern = feature.downcase
63
63
  root = rails_app.root.to_s
64
64
  lines = [ "# Feature Analysis: #{feature}", "" ]
65
+ unread = Set.new
65
66
 
66
67
  matched_models = discover_models(ctx, pattern, lines)
67
68
  discover_controllers(ctx, pattern, lines)
68
69
  discover_routes(ctx, pattern, lines)
69
- discover_services(root, pattern, lines)
70
+ discover_services(root, pattern, lines, unread)
70
71
  discover_admin(root, pattern, lines)
71
72
  discover_jobs(ctx, pattern, lines)
72
- discover_views(ctx, root, pattern, lines)
73
+ discover_views(ctx, root, pattern, lines, unread)
73
74
  discover_stimulus(ctx, pattern, lines)
74
- _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines)
75
+ _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines, unread)
75
76
  discover_related_models(ctx, matched_models, lines)
76
77
  discover_concerns(ctx, matched_models, lines)
77
78
  discover_callbacks(ctx, matched_models, lines)
78
79
  discover_channels(ctx, pattern, lines)
79
80
  discover_mailers(ctx, pattern, lines)
80
- discover_env_dependencies(root, pattern, matched_models, lines)
81
- discover_test_gaps(root, pattern, matched_models, ctx, all_tests || [], lines, truncated: tests_truncated)
81
+ discover_env_dependencies(root, pattern, matched_models, lines, unread)
82
+ discover_test_gaps(root, pattern, matched_models, ctx, all_tests || [], lines, unread, truncated: tests_truncated)
82
83
  discover_components(ctx, pattern, lines)
83
84
 
84
85
  # For auth-related keywords, also discover auth gems
@@ -96,10 +97,17 @@ module RailsAiContext
96
97
  # If nothing was discovered, return a clean "no match" with real suggestions
97
98
  has_content = lines.any? { |l| l.start_with?("## ") || l.start_with?("### ") }
98
99
  unless has_content
99
- return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}")
100
+ return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}#{unread_note(unread)}")
100
101
  end
101
102
 
102
- text_response(lines.join("\n"))
103
+ text_response(lines.join("\n") + unread_note(unread))
104
+ end
105
+
106
+ # Files past MAX_SCAN_FILES in a directory are never read, so a section that is missing may only be unread.
107
+ def self.unread_note(unread)
108
+ return "" if unread.empty?
109
+
110
+ "\n\n_Only the first #{MAX_SCAN_FILES} files of a directory are read, so any past them in #{unread.sort.join(', ')} were not checked._"
103
111
  end
104
112
 
105
113
  # The names a keyword that matched nothing could have been.
@@ -115,6 +123,13 @@ module RailsAiContext
115
123
  class << self
116
124
  private
117
125
 
126
+ # The first MAX_SCAN_FILES of a glob; a directory holding more goes into `unread`.
127
+ def capped_glob(dir, pattern, real_root, label, unread)
128
+ files = safe_glob(dir, pattern, real_root)
129
+ unread << label if files.size > MAX_SCAN_FILES
130
+ files.first(MAX_SCAN_FILES)
131
+ end
132
+
118
133
  # A conditional skip names the condition it is taken out on.
119
134
  def inherited_filter_label(filter)
120
135
  "#{filter[:name]}#{Serializers::SectionFacts.filter_origin(filter)}" \
@@ -260,13 +275,13 @@ module RailsAiContext
260
275
  end
261
276
 
262
277
  # --- AF1: Services ---
263
- def discover_services(root, pattern, lines)
278
+ def discover_services(root, pattern, lines, unread)
264
279
  dir = File.join(root, "app", "services")
265
280
  return unless Dir.exist?(dir)
266
281
 
267
282
  real_root = File.realpath(root).to_s
268
283
  real_dir = File.realpath(dir).to_s
269
- candidates = safe_glob(dir, "**/*.rb", real_root).first(MAX_SCAN_FILES)
284
+ candidates = capped_glob(dir, "**/*.rb", real_root, "app/services", unread)
270
285
  # Prefer basename match (fast, no file read) and only fall back to
271
286
  # a content scan for files whose basename doesn't match. This
272
287
  # avoids reading every service file's full contents on large apps.
@@ -287,7 +302,7 @@ module RailsAiContext
287
302
  named = services.select { |s| s[:name] }
288
303
  callers, callers_truncated = GetServicePattern.callers_of(named.to_h { |s| [ s[:name], s[:path] ] }, real_root)
289
304
 
290
- lines << "## Services (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
305
+ lines << "## Services (#{found.size}#{unread.include?("app/services") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
291
306
  services.each do |service|
292
307
  relative = service[:path].sub("#{real_root}/", "")
293
308
  methods = service[:name] ? service_methods(service[:source], service[:name]) : []
@@ -353,19 +368,19 @@ module RailsAiContext
353
368
  end
354
369
 
355
370
  # --- AF3: Views + Partials ---
356
- def discover_views(ctx, root, pattern, lines)
371
+ def discover_views(ctx, root, pattern, lines, unread)
357
372
  views_dir = File.join(root, "app", "views")
358
373
  return unless Dir.exist?(views_dir)
359
374
 
360
375
  real_root = File.realpath(root).to_s
361
376
  real_views_dir = File.realpath(views_dir).to_s
362
- candidates = safe_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root).first(MAX_SCAN_FILES)
377
+ candidates = capped_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root, "app/views", unread)
363
378
  found = candidates.select do |path|
364
379
  feature_word_match?(path.sub("#{real_views_dir}/", ""), pattern)
365
380
  end
366
381
  return if found.empty?
367
382
 
368
- lines << "## Views (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
383
+ lines << "## Views (#{found.size}#{unread.include?("app/views") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
369
384
  found.each do |path|
370
385
  relative = path.sub("#{real_views_dir}/", "")
371
386
  source = RailsAiContext::SafeFile.read(path) or next
@@ -439,7 +454,7 @@ module RailsAiContext
439
454
  # @return [Array(Array<String>, Array<String>, Boolean)] the tests the
440
455
  # feature word names, every test read (relative to the suite's root),
441
456
  # and whether a glob stopped at MAX_SCAN_FILES
442
- def discover_tests(root, pattern, lines)
457
+ def discover_tests(root, pattern, lines, unread)
443
458
  real_root = File.realpath(root).to_s
444
459
  found = []
445
460
  all = []
@@ -449,8 +464,7 @@ module RailsAiContext
449
464
  real_base = File.realpath(base).to_s
450
465
  [ File.join(base, "spec"), File.join(base, "test") ].each do |dir|
451
466
  next unless Dir.exist?(dir)
452
- suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb", real_base).first(MAX_SCAN_FILES)
453
- truncated = true if suffix_glob.size == MAX_SCAN_FILES
467
+ suffix_glob = capped_glob(dir, "**/*_{test,spec}.rb", real_base, File.basename(dir), unread)
454
468
  # The path, not the basename: the gap checker below matches this
455
469
  # way for the same reason, and a spec whose feature word is a
456
470
  # directory (billing/invoices/create_spec.rb) is the normal shape
@@ -458,13 +472,13 @@ module RailsAiContext
458
472
  suffix_glob.each do |path|
459
473
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
460
474
  end
461
- prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb", real_base).first(MAX_SCAN_FILES)
462
- truncated = true if prefix_glob.size == MAX_SCAN_FILES
475
+ prefix_glob = capped_glob(dir, "**/{test,spec}_*.rb", real_base, File.basename(dir), unread)
463
476
  prefix_glob.each do |path|
464
477
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
465
478
  end
466
479
  all.concat(suffix_glob, prefix_glob)
467
480
  end
481
+ truncated = unread.include?("spec") || unread.include?("test")
468
482
  found.uniq!
469
483
  all = all.uniq.map { |path| path.delete_prefix("#{real_base}/") }
470
484
  return [ found, all, truncated ] if found.empty?
@@ -486,7 +500,7 @@ module RailsAiContext
486
500
  end
487
501
 
488
502
  # --- Test coverage gaps ---
489
- def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines, truncated: false)
503
+ def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines, unread, truncated: false)
490
504
  # A gap is a claim that something has no test, and a scan that stopped
491
505
  # at MAX_SCAN_FILES cannot make it: Discourse has 1,672 spec files, so
492
506
  # the first 500 missed the ones that cover the feature and every
@@ -526,7 +540,7 @@ module RailsAiContext
526
540
  path_dir = File.join(root, "app", dir)
527
541
  next unless Dir.exist?(path_dir)
528
542
 
529
- safe_glob(path_dir, "**/*.rb", real_root).first(MAX_SCAN_FILES).each do |path|
543
+ capped_glob(path_dir, "**/*.rb", real_root, "app/#{dir}", unread).each do |path|
530
544
  snake = File.basename(path, ".rb")
531
545
  next unless feature_word_match?(snake, pattern)
532
546
  next if test_basenames.any? { |t| t.include?(snake) }
@@ -653,17 +667,14 @@ module RailsAiContext
653
667
  end
654
668
 
655
669
  # --- AF9: Environment Dependencies ---
656
- def discover_env_dependencies(root, pattern, matched_models, lines)
670
+ def discover_env_dependencies(root, pattern, matched_models, lines, unread)
657
671
  # Scan services, jobs, and model files for ENV references
658
672
  real_root = File.realpath(root).to_s
659
673
  dirs = %w[app/services app/jobs].map { |d| File.join(root, d) }.select { |d| Dir.exist?(d) }
660
674
  env_vars = Set.new
661
- truncated = false
662
675
 
663
676
  dirs.each do |dir|
664
- candidates = safe_glob(dir, "**/*.rb", real_root).first(MAX_SCAN_FILES)
665
- truncated = true if candidates.size == MAX_SCAN_FILES
666
- candidates.each do |path|
677
+ capped_glob(dir, "**/*.rb", real_root, dir.delete_prefix("#{root}/"), unread).each do |path|
667
678
  next unless feature_word_match?(File.basename(path, ".rb"), pattern) || feature_word_match?(path, pattern)
668
679
  source = RailsAiContext::SafeFile.read(path) or next
669
680
  source.scan(/ENV\[["']([^"']+)["']\]|ENV\.fetch\(["']([^"']+)["']\)/).each do |m|
@@ -673,6 +684,7 @@ module RailsAiContext
673
684
  end
674
685
  return if env_vars.empty?
675
686
 
687
+ truncated = dirs.any? { |dir| unread.include?(dir.delete_prefix("#{root}/")) }
676
688
  header = "## Environment Dependencies#{truncated ? " (first #{MAX_SCAN_FILES} per dir scanned)" : ""}"
677
689
  lines << header
678
690
  env_vars.sort.each { |v| lines << "- `#{v}`" }
@@ -186,7 +186,7 @@ module RailsAiContext
186
186
  # Likely cause - enriched with specific inference when possible
187
187
  lines << "## Likely Cause"
188
188
  lines << classification[:likely]
189
- specific = infer_specific_cause(parsed, classification, file, action)
189
+ specific = infer_specific_cause(parsed, classification, file, action, line)
190
190
  lines << "" << "**Specific:** #{specific}" if specific
191
191
  lines << ""
192
192
 
@@ -614,8 +614,18 @@ module RailsAiContext
614
614
  lines << "## #{title}" << "_Could not load: #{e.message}_" << ""
615
615
  end
616
616
 
617
+ # Regex because a lone source line is no program a listener can walk.
618
+ def nil_receiver(file, line, method)
619
+ call = /((?:@@?|\$)?\w+)\.#{Regexp.escape(method)}(?![\w?!])/
620
+ located = RailsAiContext::SafePath.locate(file, under: rails_app.root.to_s)
621
+ failing = line.to_i.positive? && located.ok? && RailsAiContext::SafeFile.read(located.realpath).to_s.lines[line.to_i - 1]
622
+ return failing[call, 1] if failing
623
+
624
+ response_text(compose(GetEditContext, file: file, near: method))[call, 1]
625
+ end
626
+
617
627
  # Infer a specific diagnosis from the error + context
618
- def infer_specific_cause(parsed, classification, file, action)
628
+ def infer_specific_cause(parsed, classification, file, action, line = nil)
619
629
  msg = parsed[:message].to_s
620
630
  method = parsed[:method_name]
621
631
 
@@ -629,12 +639,8 @@ module RailsAiContext
629
639
  # Check if calling on an association
630
640
  if method && file
631
641
  begin
632
- ctx = compose(GetEditContext, file: file, near: method)
633
- code = response_text(ctx)
634
- # Find the receiver: something.method_name
635
- receiver_match = code.match(/(\w+)\.#{Regexp.escape(method)}/)
636
- if receiver_match
637
- receiver = receiver_match[1]
642
+ receiver = nil_receiver(file, line, method)
643
+ if receiver
638
644
  return "`#{receiver}` is nil when `.#{method}` is called. " \
639
645
  "This variable may not be set in all code paths - check if it's assigned before use, " \
640
646
  "or use `#{receiver}&.#{method}` for safe navigation."
@@ -793,7 +793,7 @@ module RailsAiContext
793
793
  names = res[:login].map { |login| login[:filter][:name] }.join(" and ")
794
794
  "this app's #{res[:helper]} is not known to get a request past #{names}, so send what #{names} checks"
795
795
  else
796
- "this app's tests define no sign-in helper, so sign a user in first the way the app's login does"
796
+ "no `sign_in` or `log_in` helper in this app's test helpers, so sign a user in first the way the app's login does"
797
797
  end
798
798
  [ " # TODO: #{login_phrase(ctrl_class, res)}, so each request here is redirected or refused until the test signs in: #{how}." ]
799
799
  end
@@ -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
 
@@ -570,7 +570,7 @@ module RailsAiContext
570
570
  lines << "```ruby"
571
571
  lines << "class Add#{written.camelize}To#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
572
572
  lines << " def change"
573
- lines << " add_reference :#{table}, :#{reference}#{foreign_key}"
573
+ lines << " add_reference :#{table}, :#{reference}#{key_type_option(schema, to_table)}#{foreign_key}"
574
574
  lines << " end"
575
575
  lines << "end"
576
576
  lines << "```"
@@ -617,6 +617,27 @@ module RailsAiContext
617
617
  Payload.schema_tables(schema).any? { |_, table, _| table.to_s == name.to_s }
618
618
  end
619
619
 
620
+ # The primary key type of a table when it is not the integer Rails defaults to.
621
+ def custom_key_type(table_data)
622
+ keys = Array(table_data && table_data[:columns]).select { |c| c[:primary_key] }
623
+ type = keys.first[:type].to_s if keys.size == 1
624
+ type unless type.nil? || %w[bigint integer].include?(type)
625
+ end
626
+
627
+ # ", type: :uuid" for a reference to a table keyed that way; a table the schema lacks is keyed as the app's tables are.
628
+ def key_type_option(schema, to_table)
629
+ target = to_table && schema_known?(schema) && Payload.schema_table(schema, to_table)
630
+ type = target ? custom_key_type(target) : app_key_type(schema)
631
+ type ? ", type: :#{type}" : ""
632
+ end
633
+
634
+ # The key type more than half of the app's tables share, when it is not Rails' default.
635
+ def app_key_type(schema)
636
+ tables = schema.is_a?(Hash) && schema[:tables].is_a?(Hash) ? schema[:tables].values : []
637
+ type, count = tables.map { |data| custom_key_type(data) }.tally.max_by(&:last)
638
+ type if type && count * 2 > tables.size
639
+ end
640
+
620
641
  def generate_change_type(table, column, type, options)
621
642
  return [ "**Error:** column and type are required" ] unless column && type
622
643
 
@@ -667,7 +688,9 @@ module RailsAiContext
667
688
  lines << "```ruby"
668
689
  lines << "class Create#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
669
690
  lines << " def change"
670
- lines << " create_table :#{table} do |t|"
691
+ schema = cached_context[:schema]
692
+ key_type = app_key_type(schema)
693
+ lines << " create_table :#{table}#{", id: :#{key_type}" if key_type} do |t|"
671
694
 
672
695
  if columns_str
673
696
  columns_str.split(",").each do |col|
@@ -675,7 +698,7 @@ module RailsAiContext
675
698
  name = parts[0]&.strip
676
699
  type = parts[1]&.strip || "string"
677
700
  if type == "references"
678
- lines << " t.references :#{name}, foreign_key: true"
701
+ lines << " t.references :#{name}#{key_type_option(schema, name.to_s.pluralize)}, foreign_key: true"
679
702
  else
680
703
  lines << " t.#{type} :#{name}"
681
704
  end
@@ -21,7 +21,7 @@ module RailsAiContext
21
21
  files: {
22
22
  type: "array",
23
23
  items: { type: "string" },
24
- description: "Filter to specific files (relative to Rails root). Omit to review all changed files."
24
+ description: "Filter to files or directories (relative to Rails root). Omit to review all changed files."
25
25
  }
26
26
  }
27
27
  )
@@ -91,7 +91,7 @@ module RailsAiContext
91
91
  end
92
92
 
93
93
  changed = get_changed_files(base, root)
94
- changed = changed.select { |f| files.any? { |filter| f.include?(filter) } } if files&.any?
94
+ changed = changed.select { |f| files.any? { |filter| path_matches?(f, filter) } } if files&.any?
95
95
 
96
96
  if changed.empty?
97
97
  return text_response("No changes found for ref '#{echo_input(ref)}'.#{files ? " Filter: #{echo_input(files.join(', '))}" : ""}")
@@ -214,6 +214,12 @@ module RailsAiContext
214
214
  status.success? && !prefix.strip.empty?
215
215
  end
216
216
 
217
+ # A filter names one file or a directory, never part of a name: `Gemfile` is not `Gemfile.lock`.
218
+ def path_matches?(path, filter)
219
+ wanted = filter.delete_prefix("./").chomp("/")
220
+ path == wanted || path.start_with?("#{wanted}/")
221
+ end
222
+
217
223
  # The Ruby folders count only Ruby files: the install writes app/models/AGENTS.md beside the models.
218
224
  def classify_file(path)
219
225
  case path
@@ -23,6 +23,11 @@ module RailsAiContext
23
23
  BINARY_PROBE_BYTES = 64 * 1024
24
24
 
25
25
  TEST_DIRS = %w[test/ spec/ features/].freeze
26
+
27
+ # A trace lists this many internal calls and sibling methods, then says how many it left out.
28
+ INTERNAL_CALLS_SHOWN = 15
29
+ SIBLINGS_SHOWN = 10
30
+
26
31
  MATCH_FIELD_SEPARATOR = "\x1f"
27
32
  # A comment line by the file's own syntax. `#{` and ` * ` are Ruby code;
28
33
  # `#id` is a selector in a stylesheet and an element in Haml or Slim.
@@ -768,7 +773,8 @@ module RailsAiContext
768
773
 
769
774
  if internal_calls.any?
770
775
  lines << "" << "## Calls internally"
771
- internal_calls.first(15).each { |c| lines << "- `#{c}`" }
776
+ internal_calls.first(INTERNAL_CALLS_SHOWN).each { |c| lines << "- `#{c}`" }
777
+ lines << "- _...and #{internal_calls.size - INTERNAL_CALLS_SHOWN} more_" if internal_calls.size > INTERNAL_CALLS_SHOWN
772
778
  end
773
779
  end
774
780
 
@@ -776,7 +782,8 @@ module RailsAiContext
776
782
  siblings = extract_sibling_methods(File.join(root, r[:file]), cleaned)
777
783
  if siblings.any?
778
784
  lines << "" << "## Sibling methods (same file)"
779
- siblings.first(10).each { |s| lines << "- `#{s}`" }
785
+ siblings.first(SIBLINGS_SHOWN).each { |s| lines << "- `#{s}`" }
786
+ lines << "- _...and #{siblings.size - SIBLINGS_SHOWN} more_" if siblings.size > SIBLINGS_SHOWN
780
787
  end
781
788
 
782
789
  lines << ""
@@ -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.1"
4
+ VERSION = "5.36.3"
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.1
4
+ version: 5.36.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine