rails-ai-context 5.33.0 → 5.34.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -0
  3. data/CLAUDE.md +3 -0
  4. data/CONTEXT.md +13 -1
  5. data/README.md +3 -3
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +0 -2
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +1 -1
  9. data/docs/COMPATIBILITY.md +13 -0
  10. data/docs/INTROSPECTORS.md +4 -3
  11. data/docs/SECURITY.md +1 -1
  12. data/docs/TOOLS.md +8 -1
  13. data/docs/adr/0006-strong-params-resolve-as-ruby-runs.md +10 -0
  14. data/lib/rails_ai_context/app_kind.rb +18 -5
  15. data/lib/rails_ai_context/cli/entry_boot.rb +5 -2
  16. data/lib/rails_ai_context/engine.rb +7 -0
  17. data/lib/rails_ai_context/gem_lock.rb +10 -1
  18. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  19. data/lib/rails_ai_context/introspectors/auth_introspector.rb +6 -8
  20. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  21. data/lib/rails_ai_context/introspectors/controller_introspector.rb +333 -54
  22. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  23. data/lib/rails_ai_context/introspectors/gem_introspector.rb +21 -9
  24. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +1 -1
  25. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  26. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +32 -3
  27. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  28. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  29. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +10 -3
  30. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  31. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  32. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +15 -5
  33. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  34. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  35. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  36. data/lib/rails_ai_context/introspectors/migration_replay.rb +13 -12
  37. data/lib/rails_ai_context/introspectors/model_introspector.rb +140 -18
  38. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  39. data/lib/rails_ai_context/introspectors/schema_introspector.rb +9 -12
  40. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  41. data/lib/rails_ai_context/introspectors/source_scan.rb +26 -0
  42. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  43. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/view_introspector.rb +1 -1
  45. data/lib/rails_ai_context/mcp_edge.rb +2 -5
  46. data/lib/rails_ai_context/payload.rb +37 -1
  47. data/lib/rails_ai_context/redaction.rb +12 -4
  48. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -1
  49. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +2 -1
  50. data/lib/rails_ai_context/serializers/guide_examples.rb +2 -1
  51. data/lib/rails_ai_context/serializers/markdown_serializer.rb +4 -1
  52. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -1
  53. data/lib/rails_ai_context/serializers/section_facts.rb +9 -0
  54. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -441
  55. data/lib/rails_ai_context/tools/base_tool.rb +17 -7
  56. data/lib/rails_ai_context/tools/get_controllers.rb +22 -4
  57. data/lib/rails_ai_context/tools/get_conventions.rb +2 -2
  58. data/lib/rails_ai_context/tools/get_helper_methods.rb +1 -1
  59. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  60. data/lib/rails_ai_context/tools/get_model_details.rb +14 -4
  61. data/lib/rails_ai_context/tools/get_routes.rb +10 -2
  62. data/lib/rails_ai_context/tools/get_schema.rb +1 -1
  63. data/lib/rails_ai_context/tools/onboard.rb +6 -2
  64. data/lib/rails_ai_context/tools/performance_check.rb +1 -1
  65. data/lib/rails_ai_context/tools/review_changes.rb +11 -10
  66. data/lib/rails_ai_context/tools/search_docs.rb +1 -1
  67. data/lib/rails_ai_context/tools/security_scan.rb +2 -2
  68. data/lib/rails_ai_context/tools/session_context.rb +1 -1
  69. data/lib/rails_ai_context/tools/validate_semantics.rb +1 -1
  70. data/lib/rails_ai_context/version.rb +1 -1
  71. data/server.json +1 -1
  72. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a60b2bc0a9f71133c2dc0889ba870868f49aa923b2e3d5cfbf7f7a34c0085e38
4
- data.tar.gz: 7bf500ba0442bf338e3f3a41ab655b23589db53890814958d8e9e19561839040
3
+ metadata.gz: b06974567f2b7098d9bdd1eccb11438d8327271fe07efeb986a2ca8004c0eede
4
+ data.tar.gz: 8ad3f24358bbc9e83c73baa1fbd99c5ae5ed0113aa7d5b9cc4f8f69088281236
5
5
  SHA512:
6
- metadata.gz: e29f4af14f4ad58165140901245da44490887fc9bde26a2fcdf6432d2156e108332e89b4ed067752591c9e2ac3b2a05d686db42986ac05d120aeeada013481cd
7
- data.tar.gz: b426dbf26ab302bd85d4755b5bc11dce2f22b0f0a3d9bd109604910c366fe90848ffd216235e2ea77a0e2e5c6999cb5910f3ea23d09edc96a110c1952687abc0
6
+ metadata.gz: 06f3deab68deafc795d5574fb3acbfcf9a2f4a2f568036aef7b12089ff8cff5249b15139eceff6a843d89e5d1e7840c84ea3ad3c37cab8be92e5b79f1b1300ba
7
+ data.tar.gz: 6a3fcbbf27a9aad384875a84d6a863f54d5643858db4ffa6a36be7b9e58b2a05ccdab080af4219318a46da5ba5f89b3aacde36afaa55b4650e9820016d2b259f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,222 @@ 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.34.1] - 2026-10-12
9
+
10
+ ### Changed
11
+
12
+ - **The repository tracks `CLAUDE.md`.** It points at `AGENTS.md`, and the
13
+ gem ships it beside the `AGENTS.md` and `CONTEXT.md` it already shipped.
14
+ The code is v5.34.0's.
15
+ - **The glossary defines strong params and notable gems.** `CONTEXT.md` says
16
+ what makes a method strong params and which gems count as notable, and
17
+ ADR-0006 records why a strong params call is read as the method Ruby
18
+ would run.
19
+
20
+ ## [5.34.0] - 2026-10-12
21
+
22
+ ### Security
23
+
24
+ - **A tracked log's diff hides its credentials.** `rails_review_changes`
25
+ read a `.log` file's diff as source, which filters a Bearer or Basic value
26
+ only at 16 characters, so a short one showed in clear. A log file shown as
27
+ a file, logrotate's dated and `.old` ones and a `.log.bak` included, now
28
+ gets what `rails_read_logs` filters.
29
+ - **A Digest or Token Authorization header is filtered with all its
30
+ parameters.** A Digest header in a log kept its `response=` hash, also
31
+ inside an inspected hash whose value escapes its quotes, and a Token
32
+ header kept every parameter after `token=` (and came out as
33
+ `Authorization: [FILTERED]]` when unquoted).
34
+ - **An Authorization value after an unquoted name and `=` or `=>` is
35
+ filtered.** A log line from an env dump (`HTTP_AUTHORIZATION=Basic ...`)
36
+ or a debug line (`Authorization => Bearer ...`) kept a short credential in
37
+ clear, in `rails_read_logs` and in a tracked log's diff. A switch's value
38
+ (`skip_authorization = true`) stays, and in a query string only the value
39
+ goes.
40
+
41
+ ### Added
42
+
43
+ - **A static model answer names the plugin patch modules it did not
44
+ read.** An app plugin that adds to a core model through its own DSL
45
+ (OpenProject's `patches %i[WorkPackage]`, which includes
46
+ `<Plugin>::Patches::WorkPackagePatch` in `to_prepare`) gave a
47
+ `rails_get_model_details` answer with none of what the patches add and no
48
+ sign anything was left out. A module named `*::Patches::<Model>Patch` in
49
+ the app's, its code roots' or its in-repo gems' `lib` is now named in one
50
+ `[UNAVAILABLE]` line, the way `rails_get_controllers` names an included
51
+ module it did not read.
52
+ - **`rails_get_model_details` names each `has_attached_file` attachment
53
+ under Macros.**
54
+ - **The controller listing marks `permit!` and names a parent's strong
55
+ params.** The listing marks `permit!` as the per-action view did, and
56
+ strong params a parent controller in the app defines are listed with the
57
+ parent named, unless the controller defines an instance method of that
58
+ name itself.
59
+
60
+ ### Changed
61
+
62
+ - **Notable gems are the ones the app names, and a Rails framework only when
63
+ the app loads it.** Every lockfile entry counted, so `onboard`, the gems
64
+ tool and the context files listed `activestorage` for an app that leaves
65
+ `active_storage/engine` commented out (it is in the lock because `rails`
66
+ pulls it in) and `jwt` for an app that only has it through oauth2 and
67
+ googleauth. A notable gem now counts when the Gemfile names it, when a
68
+ gemspec or path gem the Gemfile pulls in depends on it, when it is a Rails
69
+ engine or OmniAuth that another gem brings in (gds-sso mounts OmniAuth in
70
+ the app), or, for a framework `rails` pulls in, when
71
+ config/application.rb loads it (`rails/all`, its railtie, or a
72
+ `config.<framework>` line). Rails 6's `sprockets-rails` is one of those
73
+ frameworks; on Rails 7 and later a gem that pulls it in loads it, so it
74
+ counts.
75
+ - **Static routes leave out a block from a routing method they cannot read,
76
+ and say so.** A route drawn inside a method the app defines (a helper
77
+ that wraps `scope(path: "api/v1", module: "api/v1")`, versionist's
78
+ `api_version`) was drawn at the top level, so `rails_get_routes` gave a
79
+ two-route controller seven routes it does not have and gave the namespaced
80
+ controller none. Such a block is now counted among the constructs not
81
+ expanded, and a controller with no route says one may be missing behind
82
+ them. Blocks that draw in place are drawn as before: Rails'
83
+ `constraints` and `defaults`, Devise's `devise_scope` and
84
+ `authenticate`, and route_translator's `localized`; Rails' `nested` block
85
+ is drawn under its parent's path.
86
+ - **Strong params are the methods that return permitted params.** Any `def
87
+ *_params` was listed, so a method that builds associations counted while
88
+ `article_params_json`, which calls `permit`, did not. A method named
89
+ `*_params` now counts when it calls `permit`, `permit!` or `expect` on
90
+ params (directly, through a local or an ivar, on each item a block walks,
91
+ or on `ActionController::Parameters.new`), or when what it returns comes
92
+ from `super` (`super.merge(...)`), another strong params method (a call
93
+ runs the nearest class's method of that name, so one the controller or a
94
+ parent class defines counts whatever its name, such as Mastodon's
95
+ `params_slice`; any other named `*_params` or `permitted_*`, such as a
96
+ concern's or Pundit's `permitted_attributes(@post)`; and Devise's
97
+ `devise_parameter_sanitizer`), or a call given the whole params
98
+ (`PermittedAttributes::Enterprise.new(params).call`); a `before_action`
99
+ that writes into another one's params is not one. A method of any other
100
+ name counts only when it returns the permitted params themselves, through
101
+ a local, an `if`, a `case` or an explicit `return`, so a helper that
102
+ permits to build a URL, or to read one value, is left out. An action that
103
+ permits inline is left out, a public `*_params` helper is kept, `permit!`
104
+ on a `slice` lists the keys it slices, a computed filter
105
+ (`settings_attributes: UserSettings.keys`) is shown as written, not as a
106
+ scalar, and a key `require` computes is not printed as one.
107
+
108
+ ### Fixed
109
+
110
+ - **A primary key's function default shows.** `id: :uuid, default: -> {
111
+ "gen_random_uuid()" }` on a `create_table` line gave the `id` row an empty
112
+ Default in `rails_get_schema` and `rails_get_model_details`, booted and
113
+ static, which reads as "no default". The booted tier now reads a column's
114
+ `default_function`, leaving out only a serial column's own sequence as
115
+ the dump does, and schema.rb, structure.sql and the migration replay give
116
+ the key the default its line declares.
117
+ - **Two bare `validates_with` lines are both listed.** The second one was
118
+ dropped because the two shared a dedup key with no validator class in it.
119
+ - **A `validates_with` class a concern nests is listed once.** The source
120
+ name `DateValidator` never matched the class Ruby resolves,
121
+ `DateValidation::DateValidator`, so reflection listed it again and the
122
+ validation count was one too high.
123
+ - **A model whose table the database lacks is not marked verified.**
124
+ `rails_get_model_details` printed `[VERIFIED]` and `**Table:** \`tags\``
125
+ for a model with no `tags` table, and `onboard` and the context files
126
+ named the table too. Each now says `` `tags` not found `` where a live
127
+ listing lacks the table, and the tool guide's examples pass over such a
128
+ model. A database read from its dump, a secondary one included, is not
129
+ taken at its word, since a dump can leave out a view, and nor is the
130
+ primary's listing for a model on a connection of its own.
131
+ - **Callbacks on a chain a library defines are listed.** Paperclip's
132
+ `before_file_validate` and `after_post_process` were missing from
133
+ `rails_get_model_details` and `rails_get_callbacks`, booted too, because
134
+ only Active Record's own callback names were read. A call on a chain the
135
+ booted model really has, or one `define_model_callbacks` in the same file
136
+ declares, is now listed.
137
+ - **A macro inside `translation_class.class_eval` is not the model's own.**
138
+ It is listed under "Declared on another class", as one in a concern's
139
+ `translation_class.instance_eval` already was; two declared on one line
140
+ are both listed, and a modifier's condition is not listed as one.
141
+ - **The static tier keeps a model whose parent comes from a gem.**
142
+ `class Tag < ActsAsTaggableOn::Tag` was "Model 'Tag' not found". A class
143
+ in app/models on a parent the app does not define is now a model when a
144
+ database's dump holds a table it would read, one of the parent's names
145
+ (`ahoy_messages` for `EmailMessage < Ahoy::Message`) or its own, the
146
+ primary's first, and, with no schema to ask or another database's dump
147
+ not on disk, when it declares associations or scopes. Wherever the schema
148
+ can be read, an ActiveHash class that declares `has_many` stays out, and a
149
+ class on a framework class (`ActiveRecord::Relation`) is never one. The
150
+ table comes from the parent as Rails' single table inheritance decides, or
151
+ a line says the parent sets it.
152
+ - **A filtered routes answer names the other paths an engine is mounted
153
+ at.** An engine mounted three times showed only its first mount and that
154
+ mount's helpers.
155
+ - **Migration replay applies `change_column`'s options.** `null:`,
156
+ `default:`, `limit:`, `precision:` and `scale:` given to `change_column`
157
+ were dropped, so a column relaxed to `null: true` stayed NOT NULL. The
158
+ replay now records the sizes a migration declares as the dump writes
159
+ them: an integer limit picks smallint or bigint on PostgreSQL and MySQL,
160
+ a datetime keeps any precision but the default 6, MySQL sizes a text or
161
+ blob by its limit, and a size the dump leaves out stays out. A type-only
162
+ `change_column` keeps null and default on PostgreSQL and MySQL and resets
163
+ them on SQLite, as each adapter does.
164
+ - **Migration replay keeps a partial index's condition.** A Symbol
165
+ `where: :schedule_manually` and the `where:` of a `t.references ...,
166
+ index: { where: ... }` were dropped, so a partial index read as a full one.
167
+ An index's `using:`, `order:`, `opclass:` and the rest are kept too.
168
+ - **A gem installed under the app root is not the app's code.** With the
169
+ bundle in vendor/bundle, `onboard` listed a gem's ActiveJob class
170
+ (`Chewy::Strategy::ActiveJob::Worker`) as the app's background job.
171
+ - **The engine works in an app with an `AI` or `MCP` acronym.** The app's
172
+ loader and router camelize with the app's inflections: `inflect.acronym
173
+ "AI"` made Zeitwerk expect `RailsAIContext::McpController` and
174
+ `inflect.acronym "MCP"` `RailsAiContext::MCPController`, so
175
+ `bin/rails zeitwerk:check` and any eager load failed once the gem was in
176
+ the Gemfile; the mounted endpoint answered with an error (a 404 on Rails
177
+ 7.0 after the first request), and a production boot with eager loading
178
+ failed. The controller is now kept out of the app's loader and routed as
179
+ a class, which `bin/rails routes` shows as an inline handler.
180
+ - **`rails ai:context` runs in an app with its own top-level
181
+ `print_result`.** The gem's rake helpers were top-level methods guarded by
182
+ `unless defined?`, so an app's `print_result` in lib/tasks ran instead and
183
+ the task aborted. They now live in a module.
184
+ - **A byte-size label does not depend on the app's I18n.**
185
+ `rails_read_logs` threw its whole answer away when an app's I18n backend
186
+ raised after a failed boot (Discourse's backend queries the database),
187
+ and `rails_runtime_info` and `rails_get_test_info` could do the same.
188
+ - **The static fallback loads after a Bundler.setup that failed part way.**
189
+ An app that activates a gem before `bundler/setup` (Consul's
190
+ `require "sassc-embedded"`) at a version its lock does not pin made
191
+ Bundler raise after it had marked activesupport loaded but before it added
192
+ the load paths, so the standalone CLI and `serve` died with `cannot load
193
+ such file -- active_support/core_ext/string/inflections` instead of
194
+ serving static answers.
195
+ - **The controller count is the app's own, the same from rake and from the
196
+ generator.** A gem's controller was listed whenever something had loaded
197
+ it, which flipflop's dashboard does in a generator boot and not in a rake
198
+ one, so the install wrote "Controllers (99)" and `rails ai:context` wrote
199
+ "(97)" and `ai:doctor` called the files stale. A `*_controller.rb` that
200
+ declares only a module was listed as a controller too, and one that names
201
+ another controller (`GoogleController = OAuth2Controller`) is listed as
202
+ its alias, with its actions.
203
+ - **A module in app/policies is not a policy.** `onboard` counted
204
+ `module Authorizer`, which nests a helper class, among the Pundit
205
+ policies. A policy is now the class its file is named for.
206
+ - **`rails_review_changes` does not count the install's context files as
207
+ code.** `app/models/AGENTS.md` was "1 model" and
208
+ `app/controllers/AGENTS.md` "1 controller".
209
+ - **Statements on one line replay in source order on every platform.** Ruby's
210
+ sort is not stable, so with a dozen statements in a migration a one-line
211
+ `change_table(:t) { |t| t.change ... }` could apply its `t.change` before
212
+ the block opened and lose it, and on Windows it did. A route with many
213
+ paths drew them in the same unordered way.
214
+ - **Ties keep their order in every list.** Where two entries sort equal, the
215
+ answer depended on the platform's sort: `onboard`'s key flows and
216
+ `rails_get_conventions`' key directories could name different controllers
217
+ and folders, `rails_validate` named a different five Brakeman warnings,
218
+ `rails_search_docs` a different top five, and helpers, tables, queues,
219
+ N+1 risks, security warnings, SQLite table stats, shared examples and a
220
+ session's queries came out shuffled. A one-line `create_table` in a
221
+ schema.rb that also holds a `# Could not dump table` comment gave its
222
+ columns to the wrong table.
223
+
8
224
  ## [5.33.0] - 2026-10-11
9
225
 
10
226
  ### Security
data/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # rails-ai-context
2
+
3
+ Project instructions live in `AGENTS.md`, which is committed and shared. Read it.
data/CONTEXT.md CHANGED
@@ -66,7 +66,7 @@ The other half of the same problem is the reverse trip. Once a name is the decla
66
66
 
67
67
  ## Table name
68
68
 
69
- The table a model reads. Rails builds it as `table_name_prefix` + the demodulized class name, pluralized unless `pluralize_table_names` is off, + `table_name_suffix`, unless the class assigns one itself or is an STI child, which reads its parent's. The static tier answers the same order from source: an explicit `self.table_name =`, else the table of the model it inherits from, else the prefix and suffix the enclosing modules declare around the file's own basename. That basename is pluralized unless the class, then its superclass chain, then the app sets `pluralize_table_names = false`. The app-wide affixes and pluralize setting come from `ActiveRecordSettings`, which reads config/application.rb, the environment file, the initializers and `on_load(:active_record)` blocks. Never the underscored constant - that turns `Admin::ActionLog` into `admin/action_logs` and `OAuthClientConfig` into `o_auth_client_configs`, neither of which is a table.
69
+ The table a model reads. Rails builds it as `table_name_prefix` + the demodulized class name, pluralized unless `pluralize_table_names` is off, + `table_name_suffix`, unless the class assigns one itself or is an STI child, which reads its parent's. The static tier answers the same order from source: an explicit `self.table_name =`, else the table of the model it inherits from, else the prefix and suffix the enclosing modules declare around the file's own basename. That basename is pluralized unless the class, then its superclass chain, then the app sets `pluralize_table_names = false`. A class on a parent the app does not define (`Tag < ActsAsTaggableOn::Tag`) reads the parent's table, which the static tier takes from the dumps: the one table the primary's schema holds among the parent's names and the class's own, else the one another database's dump holds. The app-wide affixes and pluralize setting come from `ActiveRecordSettings`, which reads config/application.rb, the environment file, the initializers and `on_load(:active_record)` blocks. Never the underscored constant as an answer - that turns `Admin::ActionLog` into `admin/action_logs` and `OAuthClientConfig` into `o_auth_client_configs`, neither of which is a table; a gem parent's underscored name is only a candidate the schema must hold.
70
70
 
71
71
  `TableName` reads the five declarations of one class body (`table_name`, `table_name_prefix`, `table_name_suffix`, `pluralize_table_names` and `primary_key`) and holds the two derivations; walking the namespace and the superclass chain belongs to the caller, because only it has the other files. A reader that starts from a model name instead of a file asks `TableName.for_model_name`, which takes the table the model tier already recorded before falling back to the convention. A module prefix declared outside the model directories is still invisible, an engine's in `lib/**/engine.rb` aside, so the answer stays [STATIC].
72
72
 
@@ -153,6 +153,18 @@ Which filters a controller runs, and which of them a given action runs. `ActionF
153
153
 
154
154
  **What it cannot see is stated rather than guessed.** The walk reads `ApplicationController`'s file by the one name Rails fixes, so its filters are in the chain; a parent the listing does not hold and the app has no file for - a gem-owned base other than Devise's or Doorkeeper's (there the walk resumes at the parent the initializer names), or one an inflection renames - still ends the walk. `docs/COMPATIBILITY.md` says both halves.
155
155
 
156
+ ## Strong params
157
+
158
+ A controller method that returns permitted params: it calls `permit`, `permit!` or `expect` on params, or returns what `super`, another strong params method, or a call handed the whole params gives back. What it returns makes one, never its name: a `*_params` method that builds associations is not one. A controller has its own and each superclass's that no nearer class redefines.
159
+
160
+ **A call reaches the method Ruby would run.** That is the nearest class in the controller's chain that defines the name, so a parent's helper counts whatever it is called (Mastodon's `params_slice`), a child that redefines it decides for every class above it, and a class method or a nested class's method of that name redefines nothing. A name none of the controller's classes defines, a concern's, a gem's or Pundit's, is taken at its `*_params` or `permitted_*` name. ADR-0006 records why.
161
+
162
+ **An action and a strong params method can be one method.** An action that permits inline is an action, not strong params. A public `*_params` method that permits is strong params, and since Rails can dispatch to it, the **Action** reading lists it too.
163
+
164
+ ## Notable gem
165
+
166
+ A gem the context calls out because it shapes how the app is built, counted only when the app itself runs it: the Gemfile names it (or a gemspec or path gem it pulls in does), it hooks into Rails on its own whichever gem brings it (a railtie or an engine, and OmniAuth), or it is a Rails framework the app loads. A gem that sits in the lockfile only because another gem needs it is not one.
167
+
156
168
  ## Payload
157
169
 
158
170
  The introspection context hash, in the sense consumers read it. `Payload` is the reading side: a section guard and pinned list readers whose key pairs a spec checks against the producing introspector's own output, so a renamed key fails a test instead of silently emptying a section everywhere. Facts rendered from it live once (`SectionFacts`); membership judgments are applied at the introspector seam, and readers trust the payload rather than re-filtering it.
data/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  *Give your AI coding assistant ground truth about your Rails app*
6
6
 
7
- [![Gem Version](https://img.shields.io/gem/v/rails-ai-context?style=flat-square&color=brightgreen)](https://rubygems.org/gems/rails-ai-context)
8
- [![Downloads](https://img.shields.io/gem/dt/rails-ai-context?style=flat-square&color=blue)](https://rubygems.org/gems/rails-ai-context)
9
- [![CI](https://img.shields.io/github/actions/workflow/status/crisnahine/rails-ai-context/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/crisnahine/rails-ai-context/actions)
7
+ [![Gem Version](https://img.shields.io/gem/v/rails-ai-context?style=flat-square&color=brightgreen&cacheSeconds=21600)](https://rubygems.org/gems/rails-ai-context)
8
+ [![Downloads](https://img.shields.io/gem/dt/rails-ai-context?style=flat-square&color=blue&cacheSeconds=21600)](https://rubygems.org/gems/rails-ai-context)
9
+ [![CI](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml)
10
10
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-green?style=flat-square)](https://registry.modelcontextprotocol.io)
11
11
  [![Ruby](https://img.shields.io/badge/Ruby-3.1_to_4.0-CC342D?style=flat-square&logo=ruby&logoColor=white)](https://github.com/crisnahine/rails-ai-context)
12
12
  [![Rails](https://img.shields.io/badge/Rails-7.0_to_8.1-CC0000?style=flat-square&logo=rubyonrails&logoColor=white)](https://github.com/crisnahine/rails-ai-context)
@@ -169,8 +169,6 @@ module RailsAiContext
169
169
  end
170
170
 
171
171
  class << self
172
- # The transport outlives this class, which the app's reloader replaces
173
- # on every edit in development (McpEdge.engine_transport).
174
172
  def mcp_transport
175
173
  McpEdge.engine_transport
176
174
  end
data/config/routes.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RailsAiContext::Engine.routes.draw do
4
- # The engine is not namespace-isolated, so the controller must be
5
- # referenced by its full path or Rails resolves a top-level McpController.
6
- match "/", to: "rails_ai_context/mcp#handle", via: :all
4
+ # The class, not "rails_ai_context/mcp", which the router camelizes with the app's inflections.
5
+ match "/", to: RailsAiContext::McpController.action(:handle), via: :all
7
6
  end
data/docs/ARCHITECTURE.md CHANGED
@@ -26,7 +26,7 @@ graph TD
26
26
  subgraph engine["Introspection Engine"]
27
27
  direction LR
28
28
  I["Introspectors\n40 modules\nPresets\nCached"]
29
- AST["AST Engine\nPrism\n44 listeners\nConfidence tags"]
29
+ AST["AST Engine\nPrism\n45 listeners\nConfidence tags"]
30
30
  H["Hydration Layer\nSchema hints\ninjected into\ntool responses"]
31
31
  end
32
32
 
@@ -283,6 +283,19 @@ Proof sources:
283
283
  changed every 0.3 seconds; the pre-commit hook installed and run by real
284
284
  commits in an app's own repository, a monorepo's folder and a submodule;
285
285
  and the unit suite and the E2E harness on Linux, macOS and Windows.
286
+ In the v5.34.0 release QA: the 21 apps of the v5.33.0 acceptance run
287
+ (Mastodon, OpenProject, Discourse, Canvas, Forem, Whitehall, Consul, Open
288
+ Food Network, Diaspora, Errbit, Huginn, Plots2, a private app and the small
289
+ blogs), 11 booted and 10 static, their answers compared with v5.33.0's,
290
+ and their strong params and model tables compared again after the last
291
+ fix; a fresh Rails 8.1 and a fresh Rails 7.0 app through the install,
292
+ `ai:context` twice, `zeitwerk:check` with the engine mounted, MCP over the
293
+ mounted HTTP endpoint and stdio, and the standalone CLI; an app that loads
294
+ a gem before `bundler/setup`; and the unit suite and the E2E harness on
295
+ Linux, macOS and Windows.
296
+ In the v5.34.1 release QA: the gem built from the release commit and its
297
+ file list read, and the unit suite and the E2E harness on Linux, macOS and
298
+ Windows; the code is v5.34.0's.
286
299
  2. Non-crash coverage for every built-in tool including `get_view` in
287
300
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
288
301
  (ivar cross-check, render-form detection, partial interfaces) verified
@@ -183,8 +183,8 @@ It runs a single-pass Dispatcher that walks the AST once and feeds events to all
183
183
  | ValidationsListener | `validates`, `validates_*_of`, custom `validate :method`, under the options of an enclosing `with_options` block |
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
- | 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 |
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 |
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) |
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
 
@@ -194,6 +194,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
194
194
 
195
195
  | Listener | What it detects |
196
196
  |:---------|:---------------|
197
+ | ForeignEvalsListener | A class body's blocks evaluated on another receiver (`translation_class.class_eval do`, `other.instance_eval do`), outside any `def`, with every outermost call inside by the line it starts on, an `if`/`unless` condition left out. The model walk passes it beside the default listeners (`ModelIntrospector::MODEL_LISTENERS`) and moves what the blocks declare to "Declared on another class" |
197
198
  | GenericMacroListener | Any receiver-less macro you name: `GenericMacroListener.new(:devise, :rate_limit)`; `any_receiver: true` also counts `base.macro`, as a mixin's `self.included(base)` hook writes it. Returns args, values (with a source-slice fallback), options, option values and option nodes, `proc_lines` (the line each block, `->`, `lambda {}`, `proc {}` or `Proc.new {}` argument opens on, as `Proc#source_location` gives it), plus the nesting: `parent_offset` is the offset of the target macro call whose block this one sits in, paired against each call's own `offset` rather than its line |
198
199
  | ConditionalMacroListener | GenericMacroListener plus `condition`, the `if`/`unless`/`case` branches the call sits under (`BranchConditions`), as `if Rails.env.test?`. Controller filters read it through FilterMacroListener, so `before_action :x if Rails.env.test?` is listed with its condition |
199
200
  | FilterMacroListener | ConditionalMacroListener plus `callbacks`, what each positional argument adds, named at its node: `[:name, x]` for a symbol, string or class, `[:object, Klass]` for an instance (`Class` for an anonymous class), `[:lambda]` for `-> {}` or `lambda {}`, `[:block]` for a proc, `[:unread, source]` for anything else. Used by `ControllerFilters`, so the static tier names each callback as the booted tier does |
@@ -217,7 +218,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
217
218
  | SchemaDslListener | `schema.rb`: `create_table`, `t.string`, `t.column`, `t.index`, `add_foreign_key`, `create_enum`, `create_schema`, `create_view`, `create_virtual_table`, the `ActiveRecord::Schema[x.y]` version stamp (a nil one for the unstamped `Schema.define` of Rails before 7.0), and the comment the dumper writes for a table it could not describe. Names drop `public.` unless `raw_names:` is set, which SchemaReader uses to name them against the search path |
218
219
  | MigrationDslListener | Migration DSL: `create_table`, `add_column`, `add_index`, `add_reference`, and friends |
219
220
  | MigrationReplayListener | What a replay needs beside the DSL: `def down`, `down`/`revert` blocks, and `t.timestamps` |
220
- | RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes (a `controller:` with a leading slash is absolute, as in Rails); routing concerns (`concern` definitions replayed at each `concerns:` site), `with_options` defaults merged under each inner call, `match ... via:` (or the `via:` of an enclosing `scope`) as one route answering each verb it names (`GET|POST`, and `ANY` for `via: :all`, as the booted table has it), the controller a `scope(controller:)` or a route's own `controller:` names (with Rails' `a/b` shorthand when no action is given), and the `as:`, `param:`, `module:`, `path:` and `only:`/`except:` options. A block drawn through an app class (`ApiRouteSet::V1.draw(self) do`) takes the path and `as:` prefixes that class's `self.prefix` and `mapper_prefix` return as literals, or a literal prefix argument; a class whose prefix is not a literal, and that class's own `resources`, are counted as unexpanded. Routes drawn into an engine's table (`Spree::Core::Engine.routes.draw`) sit under the engine's namespace and carry `engine:`, which the route introspector files under that engine's mount, apart from the app's count. A file pulled in by `draw` is walked inside the scope its `draw` sits in, once per scope that draws it, so `draw :api` under `namespace :api` and `scope module: :v1` routes to `api/v1/...` at `/api/...` |
221
+ | RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes (a `controller:` with a leading slash is absolute, as in Rails); routing concerns (`concern` definitions replayed at each `concerns:` site), `with_options` defaults merged under each inner call, `match ... via:` (or the `via:` of an enclosing `scope`) as one route answering each verb it names (`GET|POST`, and `ANY` for `via: :all`, as the booted table has it), the controller a `scope(controller:)` or a route's own `controller:` names (with Rails' `a/b` shorthand when no action is given), and the `as:`, `param:`, `module:`, `path:` and `only:`/`except:` options. A block drawn through an app class (`ApiRouteSet::V1.draw(self) do`) takes the path and `as:` prefixes that class's `self.prefix` and `mapper_prefix` return as literals, or a literal prefix argument; a class whose prefix is not a literal, and that class's own `resources`, are counted as unexpanded. Routes drawn into an engine's table (`Spree::Core::Engine.routes.draw`) sit under the engine's namespace and carry `engine:`, which the route introspector files under that engine's mount, apart from the app's count. A file pulled in by `draw` is walked inside the scope its `draw` sits in, once per scope that draws it, so `draw :api` under `namespace :api` and `scope module: :v1` routes to `api/v1/...` at `/api/...`. A resource's `nested do` block draws under the parent's nested path, as a direct child would. A block from any other method draws in place only for `constraints`, `defaults`, Devise's `devise_scope`/`as`/`authenticate`/`authenticated`/`unauthenticated` and route_translator's `localized`; any other block that holds routes (an app's `api_v1 do`, versionist's `api_version`) may scope them in a way the walk cannot see, so its routes are left out and the block is counted once as unexpanded |
221
222
  | MountListener | `mount Sidekiq::Web, at: "/sidekiq"`, the hash form, and a Rack app attached with `match "/metrics", to: MetricsApp` - `mount` is that call with a name derived. Paths carry the enclosing `namespace`/`scope` prefix; a scope whose own name is an expression yields no path rather than an unprefixed one, and `scope path: nil` adds no segment. A mounted app built by a call on a constant (`Flipper::UI.app(Flipper)`) is named by that call, arguments off. A mount under `if`/`unless`/`case` carries that `condition` |
222
223
  | GemfileDslListener | `gem "name", "version"`, `group :development do ... end` and `eval_gemfile "path"` (a literal or `File.expand_path("x", __dir__)`) with the groups around it; `ruby "3.3.6"` with its `engine:` and `engine_version:`; an `:unknown_gems` entry for `gemspec` and for a `gem` or `eval_gemfile` whose argument is not a literal |
223
224
  | RakeTaskDslListener | `namespace` (with the span its block covers), `desc`, `task`, `multitask` in `.rake` files |
data/docs/SECURITY.md CHANGED
@@ -406,7 +406,7 @@ Both answer the hosts the app's `config.hosts` allows, judged by Rails' own Host
406
406
 
407
407
  `auto_mount` is `false` by default, and the engine exists only where you mount it. Mount it guarded, `if defined?(RailsAiContext::Engine)`, so the routes file still loads where the gem is not installed.
408
408
 
409
- The engine builds one transport per process, under a mutex, and keeps it outside the controller class, which the app's reloader replaces on every edit in development; a session lasts across edits.
409
+ The engine builds one transport per process, under a mutex, outside the controller class, and the app's code reloader never replaces that class, so a session lasts across edits in development.
410
410
 
411
411
  ---
412
412
 
data/docs/TOOLS.md CHANGED
@@ -95,7 +95,7 @@ ripgrep follows no symlink while it walks, so a directory linked in from the app
95
95
 
96
96
  Without ripgrep on the PATH, the Ruby fallback returns the rows ripgrep would, context lines and the line cap included, from the files ripgrep would search: no hidden files, no other symlinks followed, no binary files (a NUL byte in the first 64 KiB), and ignore files read with ripgrep's precedence, where the file type decides before depth: any `.rgignore`, then any `.ignore`, then any `.gitignore`, then `.git/info/exclude`, then the global excludes file, the deepest file first within a type. `.ignore` and `.rgignore` apply everywhere, including the directories above the app; the git files apply only inside a repository and only as far up as the nearest `.git` (a file for a worktree or submodule), whose `info/exclude` is the only one read. Patterns match case-sensitively.
97
97
 
98
- Redaction treats a value under a secret-named key as a secret unless it is clearly something else: shorter than eight characters (four under a password-type key: `password`, `passwd`, `pass`, `pin`, `passphrase`, Redis's `requirepass` and `masterauth`, the `MYSQL_PWD` and `REDISCLI_AUTH` the mysql and redis-cli clients read, and a long password word that ends a name with another joined on before it, as Postgres's `PGPASSWORD` and a camel-cased `userPassword` have), containing whitespace (a message; not under `passphrase`), a URL, an email address or a hostname (not under a password-type key), a URL query (`?mode=safe`) or a path (two slashes, no `+` or `=`), a placeholder (`<token>`, `${VAR}`, `%{var}`, a `{name}` slot, `xxxx`, `your-...`, `put-your-key-here`), a version (`2.4.1`, `v2`) or a date, an HTTP header name (`X-Api-Key`; not under a password-type key), an env name (capitals joined by `_`, led by `_` or `HTTP_` or naming a secret word, like `_DISCOURSE_API`), a name spelled from a secret word (`user_api_key`; not under a password-type key, where `admin_password` is the password), the key's own name in any case (an enum's `password: 'password'`, `currentPassword: "current_password"`), a switch's value (`true`, `false`, `null`), or not ASCII. Keys ending `_url`, `_error`, `_use`, `_file`, `_dir`, `_id` and the like (in camel case too: `tokenType`) describe a secret rather than hold one, and so does a Stimulus target, action, controller, outlet or class (`password_visibility_target`). A quoted name joined by dashes, a header or an attribute, reads with its dashes as underscores, so `"X-Api-Key" => "..."` and `req["x-auth-token"] = "..."` hold an API key and a token, while `"Content-Type"` names no secret. A camel-cased name with `Secret` or `Token` among its words (`accessToken`, `clientSecret`, `jwtSecretKey`) is a secret's name, as JSON and JavaScript spell `access_token`, in source and in a log line. A call or block whose first argument names a secret holds its value too (`ENV.fetch("SECRET_KEY_BASE", "...")`, `let(:api_key) { "..." }`, `option :client_secret, default: "..."`, a `credentials.fetch` default); a ternary's `? "a" : "b"` is two values, not a key and its value. Under a name that ends in `_KEY`, `Key` or `key` without a secret word (`ALGOLIA_ADMIN_KEY`, `apiKey`), a value is filtered only when it looks like a credential: a run of 16+ characters mixing letters and digits, or a known token format; a cache or i18n key (`views/posts/1`, `users.index.title`), a storage key (`wp-content/uploads/...`), a word, a path or a header name is kept. Log lines use the same rule. An unquoted `NAME=value` with a secret-named name, in any file (shell, Dockerfile `ENV`, compose, env, code), a flag (`--password=...`, `--requirepass=...`) included, has its value filtered unless it is `$VAR`, `${...}`, `#{...}` or ERB; the value ends at the first quote, bracket, `&`, `,`, `;` or space, so in a URL query (`?token=...&page=2`) only the value goes. A secret-named flag's value given as the next word (`redis-server --requirepass ...`, `--token ...`) is filtered, bare or quoted, when it has a digit and would be filtered under that name, so a word (`the --password option`), a metavariable (`--token TOKEN`), a variable, ERB, another flag, a `--no-...` switch, a path (`~/.ssh/id_ed25519`, `./keys/deploy.pem`, `keys/deploy.pem`) or a `name=value` list (BuildKit's `--secret id=...,env=...`) stays. A `:name=` setter symbol, a `(?<=name=` lookbehind and a regex's wildcard (`/--password=.+/`) are not assignments. The credential after an auth scheme (`Bearer`, `Basic`, `Token`), under an `Authorization` key, in a curl line or a cassette, is filtered in any file when it is shaped like one: 16 or more letters and digits, both, read past a UUID's dashes and base64's `+`, `/` and `=`; the scheme stays, and `Bearer #{token}`, `Bearer YOUR_TOKEN` and `Basic realm=...` hold no credential. A log line's `Authorization` header goes whole in every spelling: `Authorization: Basic ...`, a quoted value, a hash or JSON key, `HTTP_AUTHORIZATION`. In an example file (`*.example`, `*.sample`, `*.template`) a value under a secret-named key is kept only when it is plainly a placeholder: a placeholder shape above, or up to 20 characters of words with no digits (`changeme`, `your_password_here`). Locale files are filtered only for credential formats. In a YAML or JSON file read whole, a mapping a secret's name names holds that secret in its `value`: Heroku's `app.json` (`"STRIPE_SECRET_KEY": { "value": "..." }`, on one line or several), a YAML `api_token:` over `value: ...`, and a `render.yaml` or Kubernetes entry named by a sibling (`- key: CLIENT_SECRET`, `- name: API_TOKEN`) over its `value:`; a boolean, and the next entry's value, stay. A YAML key written as a Ruby symbol (`:password:` in `config/sidekiq.yml`) reads as its name.
98
+ Redaction treats a value under a secret-named key as a secret unless it is clearly something else: shorter than eight characters (four under a password-type key: `password`, `passwd`, `pass`, `pin`, `passphrase`, Redis's `requirepass` and `masterauth`, the `MYSQL_PWD` and `REDISCLI_AUTH` the mysql and redis-cli clients read, and a long password word that ends a name with another joined on before it, as Postgres's `PGPASSWORD` and a camel-cased `userPassword` have), containing whitespace (a message; not under `passphrase`), a URL, an email address or a hostname (not under a password-type key), a URL query (`?mode=safe`) or a path (two slashes, no `+` or `=`), a placeholder (`<token>`, `${VAR}`, `%{var}`, a `{name}` slot, `xxxx`, `your-...`, `put-your-key-here`), a version (`2.4.1`, `v2`) or a date, an HTTP header name (`X-Api-Key`; not under a password-type key), an env name (capitals joined by `_`, led by `_` or `HTTP_` or naming a secret word, like `_DISCOURSE_API`), a name spelled from a secret word (`user_api_key`; not under a password-type key, where `admin_password` is the password), the key's own name in any case (an enum's `password: 'password'`, `currentPassword: "current_password"`), a switch's value (`true`, `false`, `null`), or not ASCII. Keys ending `_url`, `_error`, `_use`, `_file`, `_dir`, `_id` and the like (in camel case too: `tokenType`) describe a secret rather than hold one, and so does a Stimulus target, action, controller, outlet or class (`password_visibility_target`). A quoted name joined by dashes, a header or an attribute, reads with its dashes as underscores, so `"X-Api-Key" => "..."` and `req["x-auth-token"] = "..."` hold an API key and a token, while `"Content-Type"` names no secret. A camel-cased name with `Secret` or `Token` among its words (`accessToken`, `clientSecret`, `jwtSecretKey`) is a secret's name, as JSON and JavaScript spell `access_token`, in source and in a log line. A call or block whose first argument names a secret holds its value too (`ENV.fetch("SECRET_KEY_BASE", "...")`, `let(:api_key) { "..." }`, `option :client_secret, default: "..."`, a `credentials.fetch` default); a ternary's `? "a" : "b"` is two values, not a key and its value. Under a name that ends in `_KEY`, `Key` or `key` without a secret word (`ALGOLIA_ADMIN_KEY`, `apiKey`), a value is filtered only when it looks like a credential: a run of 16+ characters mixing letters and digits, or a known token format; a cache or i18n key (`views/posts/1`, `users.index.title`), a storage key (`wp-content/uploads/...`), a word, a path or a header name is kept. Log lines use the same rule. An unquoted `NAME=value` with a secret-named name, in any file (shell, Dockerfile `ENV`, compose, env, code), a flag (`--password=...`, `--requirepass=...`) included, has its value filtered unless it is `$VAR`, `${...}`, `#{...}` or ERB; the value ends at the first quote, bracket, `&`, `,`, `;` or space, so in a URL query (`?token=...&page=2`) only the value goes. A secret-named flag's value given as the next word (`redis-server --requirepass ...`, `--token ...`) is filtered, bare or quoted, when it has a digit and would be filtered under that name, so a word (`the --password option`), a metavariable (`--token TOKEN`), a variable, ERB, another flag, a `--no-...` switch, a path (`~/.ssh/id_ed25519`, `./keys/deploy.pem`, `keys/deploy.pem`) or a `name=value` list (BuildKit's `--secret id=...,env=...`) stays. A `:name=` setter symbol, a `(?<=name=` lookbehind and a regex's wildcard (`/--password=.+/`) are not assignments. The credential after an auth scheme (`Bearer`, `Basic`, `Token`), under an `Authorization` key, in a curl line or a cassette, is filtered in any file when it is shaped like one: 16 or more letters and digits, both, read past a UUID's dashes and base64's `+`, `/` and `=`; the scheme stays, and `Bearer #{token}`, `Bearer YOUR_TOKEN` and `Basic realm=...` hold no credential. A log line's `Authorization` header goes whole in every spelling: `Authorization: Basic ...`, a quoted value, a hash or JSON key, `HTTP_AUTHORIZATION`, an unquoted name with `=` or `=>` (`HTTP_AUTHORIZATION=Basic ...`, `Authorization => Bearer ...`), and a `Token` or `Digest` header with all its parameters; a switch's value (`skip_authorization = true`) stays. A log file shown as a file (a tracked log in a `rails_review_changes` diff, rotated or kept ones like `.log.1`, `.log-20261001`, `.log.old` and `.log.bak` included) gets the same filtering a log line does. In an example file (`*.example`, `*.sample`, `*.template`) a value under a secret-named key is kept only when it is plainly a placeholder: a placeholder shape above, or up to 20 characters of words with no digits (`changeme`, `your_password_here`). Locale files are filtered only for credential formats. In a YAML or JSON file read whole, a mapping a secret's name names holds that secret in its `value`: Heroku's `app.json` (`"STRIPE_SECRET_KEY": { "value": "..." }`, on one line or several), a YAML `api_token:` over `value: ...`, and a `render.yaml` or Kubernetes entry named by a sibling (`- key: CLIENT_SECRET`, `- name: API_TOKEN`) over its `value:`; a boolean, and the next entry's value, stay. A YAML key written as a Ruby symbol (`:password:` in `config/sidekiq.yml`) reads as its name.
99
99
 
100
100
  ### `rails_get_edit_context`
101
101
 
@@ -254,6 +254,8 @@ defined, so was never mixed in.
254
254
 
255
255
  Controller actions with inherited filters, render map, strong params. Includes schema hints for referenced models.
256
256
 
257
+ Strong params are the methods that return permitted params. A method named `*_params` counts when it calls `permit`, `permit!` or `expect` on params (directly, through a local or an ivar, on each item a block walks, or on `ActionController::Parameters.new`), or when what it returns comes from `super` (`super.merge(...)`), another strong params method (a call runs the nearest class's method of that name, so one the controller or a parent class defines counts whatever its name, such as `params_slice(:min_id)`; any other named `*_params` or `permitted_*`, such as a concern's or Pundit's `permitted_attributes(@post)`; and Devise's `devise_parameter_sanitizer`), or a call given the whole params (`Filter.new(params).call`), so a `before_action` that writes into another one's params is not one; a method of another name counts only when it returns the permitted params themselves. A parent controller's strong params are listed with the parent named unless the controller defines an instance method of that name itself, `permit!` is marked, and an action that permits inline is not one. A file that names another controller (`GoogleController = OAuth2Controller`) is listed as its alias, with that controller's actions. [ADR-0006](adr/0006-strong-params-resolve-as-ruby-runs.md) records why a call is read as the nearest class's method.
258
+
257
259
  Rails counts every public method that a concern or a parent controller gives among a controller's actions. A `page_info` in a concern that `ApplicationController` includes is an action of each controller. An app keeps the helpers its controllers share in such places, so the action lists take one of these methods only where a route names it for that controller, booted or not. An `action` lookup also finds one that no route names, under the rules the lists apply: no callback, no name that starts with `_` or ends in `?` or `!`, and no required argument. The answer reads the method from the file that defines it. It names the parent controller under **Inherited** when the method comes from one, and says under **Not listed** why the lists leave the method out. A private `def` of the same name lower down hides the method, as it does from Rails.
258
260
 
259
261
  | Parameter | Type | Default | Description |
@@ -490,6 +492,11 @@ Database config, auth framework, assets, cache, queue, Action Cable. The `use` a
490
492
  Notable gems with versions, categories, and config file locations. A config
491
493
  location is printed only when the app has that file, and an initializer is
492
494
  found under a load-order prefix too (`config/initializers/3_omniauth.rb`).
495
+ A notable gem counts when the Gemfile names it, when a gemspec or path gem the
496
+ Gemfile pulls in depends on it, when it is a Rails engine or OmniAuth that
497
+ another gem brings in, or, for a framework `rails` pulls in, when
498
+ config/application.rb loads it; a gem the lockfile holds only for another
499
+ gem's own use is left out.
493
500
 
494
501
  | Parameter | Type | Default | Description |
495
502
  |:----------|:-----|:--------|:------------|
@@ -0,0 +1,10 @@
1
+ # Strong params resolve a call as Ruby would run it
2
+
3
+ Status: accepted
4
+
5
+ A strong params method can return another one's answer (`params_slice(:min_id).merge(page: true)`), so whether it counts depends on what the call reaches. We decide that the way Ruby dispatches: across the controller's classes the nearest one defining the name wins, and a method counts when the one it reaches is strong params, found as a least fixpoint over the whole chain. Only a name none of those classes defines (a concern's, a gem's, Pundit's) falls back to the `*_params` or `permitted_*` name.
6
+
7
+ ## Considered Options
8
+
9
+ - **Each file on its own, any other name taken at its name.** Cheap and simple, and the rule until the last fixes before v5.34.0, where a real-app check and two review rounds found wrong answers it could not fix without another special case: a parent's helper named `params_slice` dropped, a parent's non-strong `*_params` counted, a child's redefinition of a grandparent's helper ignored, a class method of the same name hiding the parent's. Do not go back to it to save the chain walk; the cost is one memoized read per source.
10
+ - **Reading concerns as classes in the chain.** Ruby puts an included module between a class and its parent, so a concern's method does win. We take a concern's name instead, because listing a concern's strong params is a new answer no surface has asked for.
@@ -22,19 +22,32 @@ module RailsAiContext
22
22
  RailsAiContext::Introspectors::GemfileGems.names(root.to_s).intersect?(names)
23
23
  end
24
24
 
25
- ACTIVE_RECORD_REQUIRES = %w[rails/all active_record/railtie active_record].freeze
25
+ # The file config/application.rb requires to load each framework gem, the list rails/all requires
26
+ # (Sprockets until 7.0).
27
+ FRAMEWORK_RAILTIES = {
28
+ "activerecord" => "active_record/railtie", "activestorage" => "active_storage/engine",
29
+ "actionmailer" => "action_mailer/railtie", "actionmailbox" => "action_mailbox/engine",
30
+ "actiontext" => "action_text/engine", "actioncable" => "action_cable/engine", "activejob" => "active_job/railtie",
31
+ "sprockets-rails" => "sprockets/railtie"
32
+ }.freeze
33
+ private_constant :FRAMEWORK_RAILTIES
26
34
 
27
- # False only when config/application.rb picks its frameworks railtie by
28
- # railtie and leaves Active Record's out (a require, or a list it requires
29
- # in a loop) and never configures it; a file it cannot read is taken to load it.
30
35
  def active_record?(root)
36
+ loads_framework?(root, "activerecord")
37
+ end
38
+
39
+ # Whether config/application.rb loads this framework: not when it picks its frameworks railtie by railtie
40
+ # (a require, or a list it requires in a loop) and leaves this one out; a file it cannot read loads it.
41
+ def loads_framework?(root, gem_name)
42
+ railtie = FRAMEWORK_RAILTIES[gem_name] or return false
43
+ framework = File.dirname(railtie)
31
44
  source = RailsAiContext::SafeFile.read(File.join(root.to_s, "config", "application.rb"))
32
45
  tree = source && RailsAiContext::AstCache.parse_string(source)
33
46
  return true unless tree && tree.errors.empty?
34
47
 
35
48
  nodes = Introspectors::AstWalk.each(tree.value).to_a
36
49
  strings = nodes.grep(Prism::StringNode).map(&:unescaped)
37
- return true if strings.intersect?(ACTIVE_RECORD_REQUIRES) || nodes.grep(Prism::CallNode).any? { |n| n.name == :active_record }
50
+ return true if strings.intersect?([ "rails/all", railtie, framework ]) || nodes.grep(Prism::CallNode).any? { |n| n.name == framework.to_sym }
38
51
 
39
52
  strings.none? { |s| s.end_with?("/railtie", "/engine") }
40
53
  rescue StandardError, ScriptError
@@ -151,11 +151,14 @@ module RailsAiContext
151
151
  Outcome.new(tier: :booted, reason: nil, messages: messages)
152
152
  end
153
153
 
154
- # Splices the stripped load paths back, minus what a completed Bundler.setup pinned.
154
+ # Splices the stripped load paths back, minus what a completed Bundler.setup pinned, default gems included.
155
+ # A setup that raised part way marked gems loaded without their paths, so those are spliced too.
155
156
  def self.require_gem_without_app!
156
157
  preboot_gem_specs.each do |name, spec|
157
- next if Gem.loaded_specs.key?(name)
158
+ loaded = Gem.loaded_specs[name]
159
+ next if loaded && (loaded.default_gem? || loaded.full_require_paths.all? { |p| $LOAD_PATH.include?(p) })
158
160
 
161
+ Gem.loaded_specs[name] = spec
159
162
  spec.full_require_paths.each { |p| $LOAD_PATH.unshift(p) unless $LOAD_PATH.include?(p) }
160
163
  end
161
164
  require "rails_ai_context"
@@ -2,6 +2,13 @@
2
2
 
3
3
  module RailsAiContext
4
4
  class Engine < ::Rails::Engine
5
+ # The app's loader would camelize the controller's path with the app's inflections, and an
6
+ # acronym ("AI", "MCP") names a constant the file does not define, so Ruby autoloads it instead.
7
+ initializer "rails_ai_context.controller" do
8
+ Rails.autoloaders.main.ignore(root.join("app/controllers").to_s)
9
+ RailsAiContext.autoload(:McpController, root.join("app/controllers/rails_ai_context/mcp_controller").to_s)
10
+ end
11
+
5
12
  # The YAML is the base a configure block overrides key by key, so it has
6
13
  # to be in place before config/initializers runs.
7
14
  initializer "rails_ai_context.config_file", before: :load_config_initializers do |_app|
@@ -17,7 +17,9 @@ module RailsAiContext
17
17
  module GemLock
18
18
  MAX_SIZE = 5 * 1024 * 1024
19
19
  SPEC_LINE = /\A {4}(\S+) \(([^)]+)\)\s*\z/
20
- DEPENDENCY_LINE = /\A {2}(\S+?)!?(?: \(.*\))?\s*\z/
20
+ SPEC_DEPENDENCY_LINE = /\A {6}(\S+)/
21
+ # Bundler puts the "!" of a gem from its own source after the requirement: "sidekiq-pro (= 8.0.3)!".
22
+ DEPENDENCY_LINE = /\A {2}(\S+?)!?(?: \(.*\))?!?\s*\z/
21
23
  # Bundler writes a non-CRuby engine after the version: "ruby 3.1.4p0 (jruby 9.4.8.0)".
22
24
  RUBY_LINE = /\A\s+ruby (\S+)(?: \((\S+) ([^)]+)\))?/
23
25
  REMOTE_LINE = /\A {2}remote: (.+?)\s*\z/
@@ -297,6 +299,8 @@ module RailsAiContext
297
299
  in_path = false
298
300
  in_git = false
299
301
  path_remotes = []
302
+ path_dependencies = {}
303
+ spec_name = nil
300
304
  sources = {}
301
305
  specs_section = false
302
306
  content.each_line do |line|
@@ -317,7 +321,10 @@ module RailsAiContext
317
321
  # per platform. The text before the first hyphen is the version; a
318
322
  # prerelease tag ("1.70.0-beta1") is dropped along with the platform.
319
323
  versions[match[1]] ||= match[2].split("-", 2).first
324
+ spec_name = match[1]
320
325
  sources[match[1]] ||= if in_path then [ :path, path_remotes.last ] elsif in_git then [ :git ] else [ :gem ] end
326
+ elsif in_specs && in_path && spec_name && (match = line.match(SPEC_DEPENDENCY_LINE))
327
+ (path_dependencies[spec_name] ||= []) << match[1]
321
328
  elsif (match = line.match(RUBY_LINE)) && match[1].match?(PLAIN_VERSION)
322
329
  ruby_version = [ match[1], engine_name(match[2], match[3]) ]
323
330
  end
@@ -327,6 +334,8 @@ module RailsAiContext
327
334
  # and answering it as an app with no gems denies every gem it holds.
328
335
  return Spec.new({}, reason: "#{File.basename(path)} has no specs section") unless specs_section
329
336
 
337
+ # A gemspec or `path:` gem the Gemfile names brings its runtime dependencies in as the Gemfile's own, as Bundler resolves them.
338
+ path_dependencies.each { |name, deps| direct.merge(deps) if direct.include?(name) }
330
339
  facts = gemfile(bundle)
331
340
  Spec.new(versions, **declared_ruby(ruby_version, root, bundle, facts), direct: direct, path_remotes: path_remotes,
332
341
  sources: sources)
@@ -41,7 +41,7 @@ module RailsAiContext
41
41
  end
42
42
 
43
43
  def collect(settings, found)
44
- settings.sort_by { |entry, _| entry[:location] }.each do |entry, name|
44
+ settings.sort_by.with_index { |(entry, _), i| [ entry[:location], i ] }.each do |entry, name|
45
45
  allowed = SETTINGS[name]
46
46
  next unless entry[:assignment] && allowed
47
47