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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -0
- data/docs/COMPATIBILITY.md +13 -0
- data/docs/INTROSPECTORS.md +3 -3
- data/docs/TOOLS.md +20 -17
- data/lib/rails_ai_context/app_databases.rb +15 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +8 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +31 -11
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +2 -0
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +26 -1
- data/lib/rails_ai_context/introspectors/model_introspector.rb +49 -3
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +2 -9
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +13 -9
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +14 -1
- data/lib/rails_ai_context/introspectors/table_name.rb +15 -4
- data/lib/rails_ai_context/introspectors/test_introspector.rb +14 -5
- data/lib/rails_ai_context/payload.rb +18 -1
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +5 -3
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -2
- data/lib/rails_ai_context/tools/analyze_feature.rb +37 -25
- data/lib/rails_ai_context/tools/diagnose.rb +14 -8
- data/lib/rails_ai_context/tools/generate_test.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +26 -3
- data/lib/rails_ai_context/tools/review_changes.rb +8 -2
- data/lib/rails_ai_context/tools/search_code.rb +9 -2
- data/lib/rails_ai_context/tools/session_context.rb +3 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecc8cfb0b751983a682c2a2c0bd2082216595b83f12f679d190506425d60ae48
|
|
4
|
+
data.tar.gz: e0674fed2e5f40ebe3f33166c59d9c17bcd5c5160352100cf8b38d87a73387a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -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
|
data/docs/INTROSPECTORS.md
CHANGED
|
@@ -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
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`lib/tasks`, `rakelib`),
|
|
150
|
-
first 15 at `standard`,
|
|
151
|
-
Then the app's own generators under `lib/generators` (the
|
|
152
|
-
command and its USAGE line), the `lib/templates` files
|
|
153
|
-
generator's template, and the Railties under `lib/`
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
485
|
-
|
|
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.
|
|
182
|
-
raise ToolNotFoundError,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "**/#{
|
|
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, "**/#{
|
|
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,
|
|
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 =
|
|
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.
|
|
49
|
-
#
|
|
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))
|
|
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
|
|
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 =
|
|
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}#{
|
|
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 =
|
|
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}#{
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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|
|
|
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(
|
|
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(
|
|
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)
|