rails-ai-context 5.33.0 → 5.34.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +204 -0
  3. data/CONTEXT.md +1 -1
  4. data/README.md +3 -3
  5. data/app/controllers/rails_ai_context/mcp_controller.rb +0 -2
  6. data/config/routes.rb +2 -3
  7. data/docs/ARCHITECTURE.md +1 -1
  8. data/docs/COMPATIBILITY.md +10 -0
  9. data/docs/INTROSPECTORS.md +4 -3
  10. data/docs/SECURITY.md +1 -1
  11. data/docs/TOOLS.md +8 -1
  12. data/lib/rails_ai_context/app_kind.rb +18 -5
  13. data/lib/rails_ai_context/cli/entry_boot.rb +5 -2
  14. data/lib/rails_ai_context/engine.rb +7 -0
  15. data/lib/rails_ai_context/gem_lock.rb +10 -1
  16. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  17. data/lib/rails_ai_context/introspectors/auth_introspector.rb +6 -8
  18. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  19. data/lib/rails_ai_context/introspectors/controller_introspector.rb +333 -54
  20. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  21. data/lib/rails_ai_context/introspectors/gem_introspector.rb +21 -9
  22. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +1 -1
  23. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  24. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +32 -3
  25. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  26. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  27. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +10 -3
  28. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  29. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  30. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +15 -5
  31. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  32. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  33. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  34. data/lib/rails_ai_context/introspectors/migration_replay.rb +13 -12
  35. data/lib/rails_ai_context/introspectors/model_introspector.rb +140 -18
  36. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  37. data/lib/rails_ai_context/introspectors/schema_introspector.rb +9 -12
  38. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  39. data/lib/rails_ai_context/introspectors/source_scan.rb +26 -0
  40. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  41. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +1 -1
  42. data/lib/rails_ai_context/introspectors/view_introspector.rb +1 -1
  43. data/lib/rails_ai_context/mcp_edge.rb +2 -5
  44. data/lib/rails_ai_context/payload.rb +37 -1
  45. data/lib/rails_ai_context/redaction.rb +12 -4
  46. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -1
  47. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +2 -1
  48. data/lib/rails_ai_context/serializers/guide_examples.rb +2 -1
  49. data/lib/rails_ai_context/serializers/markdown_serializer.rb +4 -1
  50. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -1
  51. data/lib/rails_ai_context/serializers/section_facts.rb +9 -0
  52. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -441
  53. data/lib/rails_ai_context/tools/base_tool.rb +17 -7
  54. data/lib/rails_ai_context/tools/get_controllers.rb +22 -4
  55. data/lib/rails_ai_context/tools/get_conventions.rb +2 -2
  56. data/lib/rails_ai_context/tools/get_helper_methods.rb +1 -1
  57. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  58. data/lib/rails_ai_context/tools/get_model_details.rb +14 -4
  59. data/lib/rails_ai_context/tools/get_routes.rb +10 -2
  60. data/lib/rails_ai_context/tools/get_schema.rb +1 -1
  61. data/lib/rails_ai_context/tools/onboard.rb +6 -2
  62. data/lib/rails_ai_context/tools/performance_check.rb +1 -1
  63. data/lib/rails_ai_context/tools/review_changes.rb +11 -10
  64. data/lib/rails_ai_context/tools/search_docs.rb +1 -1
  65. data/lib/rails_ai_context/tools/security_scan.rb +2 -2
  66. data/lib/rails_ai_context/tools/session_context.rb +1 -1
  67. data/lib/rails_ai_context/tools/validate_semantics.rb +1 -1
  68. data/lib/rails_ai_context/version.rb +1 -1
  69. data/server.json +1 -1
  70. metadata +2 -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: e3129c92d99c38d7980f17c705e5b957276a29f9ad997d6679e90dc11c00749a
4
+ data.tar.gz: 17d8db1739e11c246c0a2efce059682daf9cb564b6745bf4db4dcbf0750bd091
5
5
  SHA512:
6
- metadata.gz: e29f4af14f4ad58165140901245da44490887fc9bde26a2fcdf6432d2156e108332e89b4ed067752591c9e2ac3b2a05d686db42986ac05d120aeeada013481cd
7
- data.tar.gz: b426dbf26ab302bd85d4755b5bc11dce2f22b0f0a3d9bd109604910c366fe90848ffd216235e2ea77a0e2e5c6999cb5910f3ea23d09edc96a110c1952687abc0
6
+ metadata.gz: f8297d9939243f9575baa725c5a10912c2def888f86a4ee918c95f54dec07516077ee55fc5e593d9ab1ee43bb7b7e9c32187db4d0d79f5e7f32abb2bb35b5e36
7
+ data.tar.gz: 026cbb700cfadaf16e6d16f760456fe9d3d0a4f5486eb69406a00abe22a96789558310e044ef5ea2e791b8e730d8c21a064bb125c8d35c97e96aabc8a6835047
data/CHANGELOG.md CHANGED
@@ -5,6 +5,210 @@ 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.0] - 2026-10-12
9
+
10
+ ### Security
11
+
12
+ - **A tracked log's diff hides its credentials.** `rails_review_changes`
13
+ read a `.log` file's diff as source, which filters a Bearer or Basic value
14
+ only at 16 characters, so a short one showed in clear. A log file shown as
15
+ a file, logrotate's dated and `.old` ones and a `.log.bak` included, now
16
+ gets what `rails_read_logs` filters.
17
+ - **A Digest or Token Authorization header is filtered with all its
18
+ parameters.** A Digest header in a log kept its `response=` hash, also
19
+ inside an inspected hash whose value escapes its quotes, and a Token
20
+ header kept every parameter after `token=` (and came out as
21
+ `Authorization: [FILTERED]]` when unquoted).
22
+ - **An Authorization value after an unquoted name and `=` or `=>` is
23
+ filtered.** A log line from an env dump (`HTTP_AUTHORIZATION=Basic ...`)
24
+ or a debug line (`Authorization => Bearer ...`) kept a short credential in
25
+ clear, in `rails_read_logs` and in a tracked log's diff. A switch's value
26
+ (`skip_authorization = true`) stays, and in a query string only the value
27
+ goes.
28
+
29
+ ### Added
30
+
31
+ - **A static model answer names the plugin patch modules it did not
32
+ read.** An app plugin that adds to a core model through its own DSL
33
+ (OpenProject's `patches %i[WorkPackage]`, which includes
34
+ `<Plugin>::Patches::WorkPackagePatch` in `to_prepare`) gave a
35
+ `rails_get_model_details` answer with none of what the patches add and no
36
+ sign anything was left out. A module named `*::Patches::<Model>Patch` in
37
+ the app's, its code roots' or its in-repo gems' `lib` is now named in one
38
+ `[UNAVAILABLE]` line, the way `rails_get_controllers` names an included
39
+ module it did not read.
40
+ - **`rails_get_model_details` names each `has_attached_file` attachment
41
+ under Macros.**
42
+ - **The controller listing marks `permit!` and names a parent's strong
43
+ params.** The listing marks `permit!` as the per-action view did, and
44
+ strong params a parent controller in the app defines are listed with the
45
+ parent named, unless the controller defines an instance method of that
46
+ name itself.
47
+
48
+ ### Changed
49
+
50
+ - **Notable gems are the ones the app names, and a Rails framework only when
51
+ the app loads it.** Every lockfile entry counted, so `onboard`, the gems
52
+ tool and the context files listed `activestorage` for an app that leaves
53
+ `active_storage/engine` commented out (it is in the lock because `rails`
54
+ pulls it in) and `jwt` for an app that only has it through oauth2 and
55
+ googleauth. A notable gem now counts when the Gemfile names it, when a
56
+ gemspec or path gem the Gemfile pulls in depends on it, when it is a Rails
57
+ engine or OmniAuth that another gem brings in (gds-sso mounts OmniAuth in
58
+ the app), or, for a framework `rails` pulls in, when
59
+ config/application.rb loads it (`rails/all`, its railtie, or a
60
+ `config.<framework>` line). Rails 6's `sprockets-rails` is one of those
61
+ frameworks; on Rails 7 and later a gem that pulls it in loads it, so it
62
+ counts.
63
+ - **Static routes leave out a block from a routing method they cannot read,
64
+ and say so.** A route drawn inside a method the app defines (a helper
65
+ that wraps `scope(path: "api/v1", module: "api/v1")`, versionist's
66
+ `api_version`) was drawn at the top level, so `rails_get_routes` gave a
67
+ two-route controller seven routes it does not have and gave the namespaced
68
+ controller none. Such a block is now counted among the constructs not
69
+ expanded, and a controller with no route says one may be missing behind
70
+ them. Blocks that draw in place are drawn as before: Rails'
71
+ `constraints` and `defaults`, Devise's `devise_scope` and
72
+ `authenticate`, and route_translator's `localized`; Rails' `nested` block
73
+ is drawn under its parent's path.
74
+ - **Strong params are the methods that return permitted params.** Any `def
75
+ *_params` was listed, so a method that builds associations counted while
76
+ `article_params_json`, which calls `permit`, did not. A method named
77
+ `*_params` now counts when it calls `permit`, `permit!` or `expect` on
78
+ params (directly, through a local or an ivar, on each item a block walks,
79
+ or on `ActionController::Parameters.new`), or when what it returns comes
80
+ from `super` (`super.merge(...)`), another strong params method (a call
81
+ runs the nearest class's method of that name, so one the controller or a
82
+ parent class defines counts whatever its name, such as Mastodon's
83
+ `params_slice`; any other named `*_params` or `permitted_*`, such as a
84
+ concern's or Pundit's `permitted_attributes(@post)`; and Devise's
85
+ `devise_parameter_sanitizer`), or a call given the whole params
86
+ (`PermittedAttributes::Enterprise.new(params).call`); a `before_action`
87
+ that writes into another one's params is not one. A method of any other
88
+ name counts only when it returns the permitted params themselves, through
89
+ a local, an `if`, a `case` or an explicit `return`, so a helper that
90
+ permits to build a URL, or to read one value, is left out. An action that
91
+ permits inline is left out, a public `*_params` helper is kept, `permit!`
92
+ on a `slice` lists the keys it slices, a computed filter
93
+ (`settings_attributes: UserSettings.keys`) is shown as written, not as a
94
+ scalar, and a key `require` computes is not printed as one.
95
+
96
+ ### Fixed
97
+
98
+ - **A primary key's function default shows.** `id: :uuid, default: -> {
99
+ "gen_random_uuid()" }` on a `create_table` line gave the `id` row an empty
100
+ Default in `rails_get_schema` and `rails_get_model_details`, booted and
101
+ static, which reads as "no default". The booted tier now reads a column's
102
+ `default_function`, leaving out only a serial column's own sequence as
103
+ the dump does, and schema.rb, structure.sql and the migration replay give
104
+ the key the default its line declares.
105
+ - **Two bare `validates_with` lines are both listed.** The second one was
106
+ dropped because the two shared a dedup key with no validator class in it.
107
+ - **A `validates_with` class a concern nests is listed once.** The source
108
+ name `DateValidator` never matched the class Ruby resolves,
109
+ `DateValidation::DateValidator`, so reflection listed it again and the
110
+ validation count was one too high.
111
+ - **A model whose table the database lacks is not marked verified.**
112
+ `rails_get_model_details` printed `[VERIFIED]` and `**Table:** \`tags\``
113
+ for a model with no `tags` table, and `onboard` and the context files
114
+ named the table too. Each now says `` `tags` not found `` where a live
115
+ listing lacks the table, and the tool guide's examples pass over such a
116
+ model. A database read from its dump, a secondary one included, is not
117
+ taken at its word, since a dump can leave out a view, and nor is the
118
+ primary's listing for a model on a connection of its own.
119
+ - **Callbacks on a chain a library defines are listed.** Paperclip's
120
+ `before_file_validate` and `after_post_process` were missing from
121
+ `rails_get_model_details` and `rails_get_callbacks`, booted too, because
122
+ only Active Record's own callback names were read. A call on a chain the
123
+ booted model really has, or one `define_model_callbacks` in the same file
124
+ declares, is now listed.
125
+ - **A macro inside `translation_class.class_eval` is not the model's own.**
126
+ It is listed under "Declared on another class", as one in a concern's
127
+ `translation_class.instance_eval` already was; two declared on one line
128
+ are both listed, and a modifier's condition is not listed as one.
129
+ - **The static tier keeps a model whose parent comes from a gem.**
130
+ `class Tag < ActsAsTaggableOn::Tag` was "Model 'Tag' not found". A class
131
+ in app/models on a parent the app does not define is now a model when a
132
+ database's dump holds a table it would read, one of the parent's names
133
+ (`ahoy_messages` for `EmailMessage < Ahoy::Message`) or its own, the
134
+ primary's first, and, with no schema to ask or another database's dump
135
+ not on disk, when it declares associations or scopes. Wherever the schema
136
+ can be read, an ActiveHash class that declares `has_many` stays out, and a
137
+ class on a framework class (`ActiveRecord::Relation`) is never one. The
138
+ table comes from the parent as Rails' single table inheritance decides, or
139
+ a line says the parent sets it.
140
+ - **A filtered routes answer names the other paths an engine is mounted
141
+ at.** An engine mounted three times showed only its first mount and that
142
+ mount's helpers.
143
+ - **Migration replay applies `change_column`'s options.** `null:`,
144
+ `default:`, `limit:`, `precision:` and `scale:` given to `change_column`
145
+ were dropped, so a column relaxed to `null: true` stayed NOT NULL. The
146
+ replay now records the sizes a migration declares as the dump writes
147
+ them: an integer limit picks smallint or bigint on PostgreSQL and MySQL,
148
+ a datetime keeps any precision but the default 6, MySQL sizes a text or
149
+ blob by its limit, and a size the dump leaves out stays out. A type-only
150
+ `change_column` keeps null and default on PostgreSQL and MySQL and resets
151
+ them on SQLite, as each adapter does.
152
+ - **Migration replay keeps a partial index's condition.** A Symbol
153
+ `where: :schedule_manually` and the `where:` of a `t.references ...,
154
+ index: { where: ... }` were dropped, so a partial index read as a full one.
155
+ An index's `using:`, `order:`, `opclass:` and the rest are kept too.
156
+ - **A gem installed under the app root is not the app's code.** With the
157
+ bundle in vendor/bundle, `onboard` listed a gem's ActiveJob class
158
+ (`Chewy::Strategy::ActiveJob::Worker`) as the app's background job.
159
+ - **The engine works in an app with an `AI` or `MCP` acronym.** The app's
160
+ loader and router camelize with the app's inflections: `inflect.acronym
161
+ "AI"` made Zeitwerk expect `RailsAIContext::McpController` and
162
+ `inflect.acronym "MCP"` `RailsAiContext::MCPController`, so
163
+ `bin/rails zeitwerk:check` and any eager load failed once the gem was in
164
+ the Gemfile; the mounted endpoint answered with an error (a 404 on Rails
165
+ 7.0 after the first request), and a production boot with eager loading
166
+ failed. The controller is now kept out of the app's loader and routed as
167
+ a class, which `bin/rails routes` shows as an inline handler.
168
+ - **`rails ai:context` runs in an app with its own top-level
169
+ `print_result`.** The gem's rake helpers were top-level methods guarded by
170
+ `unless defined?`, so an app's `print_result` in lib/tasks ran instead and
171
+ the task aborted. They now live in a module.
172
+ - **A byte-size label does not depend on the app's I18n.**
173
+ `rails_read_logs` threw its whole answer away when an app's I18n backend
174
+ raised after a failed boot (Discourse's backend queries the database),
175
+ and `rails_runtime_info` and `rails_get_test_info` could do the same.
176
+ - **The static fallback loads after a Bundler.setup that failed part way.**
177
+ An app that activates a gem before `bundler/setup` (Consul's
178
+ `require "sassc-embedded"`) at a version its lock does not pin made
179
+ Bundler raise after it had marked activesupport loaded but before it added
180
+ the load paths, so the standalone CLI and `serve` died with `cannot load
181
+ such file -- active_support/core_ext/string/inflections` instead of
182
+ serving static answers.
183
+ - **The controller count is the app's own, the same from rake and from the
184
+ generator.** A gem's controller was listed whenever something had loaded
185
+ it, which flipflop's dashboard does in a generator boot and not in a rake
186
+ one, so the install wrote "Controllers (99)" and `rails ai:context` wrote
187
+ "(97)" and `ai:doctor` called the files stale. A `*_controller.rb` that
188
+ declares only a module was listed as a controller too, and one that names
189
+ another controller (`GoogleController = OAuth2Controller`) is listed as
190
+ its alias, with its actions.
191
+ - **A module in app/policies is not a policy.** `onboard` counted
192
+ `module Authorizer`, which nests a helper class, among the Pundit
193
+ policies. A policy is now the class its file is named for.
194
+ - **`rails_review_changes` does not count the install's context files as
195
+ code.** `app/models/AGENTS.md` was "1 model" and
196
+ `app/controllers/AGENTS.md` "1 controller".
197
+ - **Statements on one line replay in source order on every platform.** Ruby's
198
+ sort is not stable, so with a dozen statements in a migration a one-line
199
+ `change_table(:t) { |t| t.change ... }` could apply its `t.change` before
200
+ the block opened and lose it, and on Windows it did. A route with many
201
+ paths drew them in the same unordered way.
202
+ - **Ties keep their order in every list.** Where two entries sort equal, the
203
+ answer depended on the platform's sort: `onboard`'s key flows and
204
+ `rails_get_conventions`' key directories could name different controllers
205
+ and folders, `rails_validate` named a different five Brakeman warnings,
206
+ `rails_search_docs` a different top five, and helpers, tables, queues,
207
+ N+1 risks, security warnings, SQLite table stats, shared examples and a
208
+ session's queries came out shuffled. A one-line `create_table` in a
209
+ schema.rb that also holds a `# Could not dump table` comment gave its
210
+ columns to the wrong table.
211
+
8
212
  ## [5.33.0] - 2026-10-11
9
213
 
10
214
  ### Security
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 schema: the table it holds among the parent's names and the class's own. 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
 
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,16 @@ 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.
286
296
  2. Non-crash coverage for every built-in tool including `get_view` in
287
297
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
288
298
  (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.
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
  |:----------|:-----|:--------|:------------|
@@ -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
 
@@ -136,7 +136,7 @@ module RailsAiContext
136
136
  { file: relative, scope: scope_parts.join(", ") }
137
137
  end
138
138
  end
139
- end.compact.sort_by { |h| h[:file] }
139
+ end.compact.sort_by.with_index { |h, i| [ h[:file], i ] }
140
140
  rescue => e
141
141
  RailsAiContext.debug_fail(e, [], label: "scan_allow_unauthenticated_access")
142
142
  end
@@ -170,13 +170,11 @@ module RailsAiContext
170
170
 
171
171
  policies_dir = File.join(root, "app/policies")
172
172
  if Dir.exist?(policies_dir)
173
- # Named from the path relative to app/policies, not the basename:
174
- # Zeitwerk makes admin/collection_policy.rb `Admin::CollectionPolicy`,
175
- # and demodulizing it collided with the top-level policy of the same
176
- # base name, so one name was listed twice and the other class could
177
- # not be reached from the context at all.
178
- policies = FileWalk.glob(policies_dir, "**/*.rb", root: root).map do |f|
179
- f.sub("#{policies_dir}/", "").delete_suffix(".rb").camelize
173
+ # Each file's own class by its full name, so admin/collection_policy.rb stays apart from
174
+ # collection_policy.rb; a file whose constant is a module is a mixin, whatever it nests.
175
+ policies = SourceScan.each(root, kind: "app/policies").filter_map do |record|
176
+ declarations = DeclaredConstant.declarations(record.source, path_name: record.path_name)
177
+ DeclaredConstant.declaration_for(declarations, record.path_name)&.name
180
178
  end.sort
181
179
  key = if gem_present?("pundit") then :pundit
182
180
  elsif gem_present?("action_policy") then :action_policy
@@ -138,7 +138,30 @@ module RailsAiContext
138
138
  declaration(entry, block_out, conditions).merge(receiver: receiver) if entry.is_a?(Hash) && entry[:location]
139
139
  end
140
140
  end
141
- end.sort_by { |entry| entry[:location].to_i }
141
+ end.sort_by.with_index { |entry, i| [ entry[:location].to_i, i ] }
142
+ end
143
+
144
+ # A class body's walk with what its blocks evaluated on another receiver declare moved to `:foreign`.
145
+ def class_body_foreign(blocks, walked)
146
+ return walked if blocks.empty?
147
+
148
+ lines = {}
149
+ kept = walked.to_h do |key, found|
150
+ next [ key, found ] unless found.is_a?(Array) && key != :methods
151
+
152
+ [ key, found.reject do |entry|
153
+ line = entry.is_a?(Hash) && entry[:location]
154
+ block = line && blocks.select { |candidate| candidate[:lines].cover?(line) }.min_by { |candidate| candidate[:span] }
155
+ next false unless block
156
+
157
+ lines[line] ||= [ block, entry[:text].to_s ]
158
+ end ]
159
+ end
160
+ foreign = lines.sort.flat_map do |line, (block, text)|
161
+ sources = Array(block[:calls][line]).map { |call| call[:source] }
162
+ (sources.empty? ? [ text ] : sources).map { |source| { declaration: source, receiver: block[:receiver], location: line } }
163
+ end
164
+ kept.merge(foreign: foreign)
142
165
  end
143
166
 
144
167
  # The branch whose condition holds the entry back; nil when every way