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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +204 -0
- data/CONTEXT.md +1 -1
- data/README.md +3 -3
- data/app/controllers/rails_ai_context/mcp_controller.rb +0 -2
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +1 -1
- data/docs/COMPATIBILITY.md +10 -0
- data/docs/INTROSPECTORS.md +4 -3
- data/docs/SECURITY.md +1 -1
- data/docs/TOOLS.md +8 -1
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/cli/entry_boot.rb +5 -2
- data/lib/rails_ai_context/engine.rb +7 -0
- data/lib/rails_ai_context/gem_lock.rb +10 -1
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +6 -8
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +333 -54
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +21 -9
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +32 -3
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +10 -3
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +15 -5
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +13 -12
- data/lib/rails_ai_context/introspectors/model_introspector.rb +140 -18
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +9 -12
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/source_scan.rb +26 -0
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/view_introspector.rb +1 -1
- data/lib/rails_ai_context/mcp_edge.rb +2 -5
- data/lib/rails_ai_context/payload.rb +37 -1
- data/lib/rails_ai_context/redaction.rb +12 -4
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/guide_examples.rb +2 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +4 -1
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/section_facts.rb +9 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -441
- data/lib/rails_ai_context/tools/base_tool.rb +17 -7
- data/lib/rails_ai_context/tools/get_controllers.rb +22 -4
- data/lib/rails_ai_context/tools/get_conventions.rb +2 -2
- data/lib/rails_ai_context/tools/get_helper_methods.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +14 -4
- data/lib/rails_ai_context/tools/get_routes.rb +10 -2
- data/lib/rails_ai_context/tools/get_schema.rb +1 -1
- data/lib/rails_ai_context/tools/onboard.rb +6 -2
- data/lib/rails_ai_context/tools/performance_check.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +11 -10
- data/lib/rails_ai_context/tools/search_docs.rb +1 -1
- data/lib/rails_ai_context/tools/security_scan.rb +2 -2
- data/lib/rails_ai_context/tools/session_context.rb +1 -1
- data/lib/rails_ai_context/tools/validate_semantics.rb +1 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3129c92d99c38d7980f17c705e5b957276a29f9ad997d6679e90dc11c00749a
|
|
4
|
+
data.tar.gz: 17d8db1739e11c246c0a2efce059682daf9cb564b6745bf4db4dcbf0750bd091
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://rubygems.org/gems/rails-ai-context)
|
|
8
|
-
[](https://rubygems.org/gems/rails-ai-context)
|
|
9
|
-
[](https://rubygems.org/gems/rails-ai-context)
|
|
8
|
+
[](https://rubygems.org/gems/rails-ai-context)
|
|
9
|
+
[](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml)
|
|
10
10
|
[](https://registry.modelcontextprotocol.io)
|
|
11
11
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
12
12
|
[](https://github.com/crisnahine/rails-ai-context)
|
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
|
|
5
|
-
|
|
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\
|
|
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
|
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -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
|
data/docs/INTROSPECTORS.md
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|