rails-ai-context 5.28.0 → 5.29.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -0
  3. data/CONTEXT.md +37 -0
  4. data/docs/INTROSPECTORS.md +16 -3
  5. data/docs/TOOLS.md +31 -12
  6. data/docs/TROUBLESHOOTING.md +13 -0
  7. data/lib/rails_ai_context/introspectors/engine_introspector.rb +6 -11
  8. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +29 -7
  9. data/lib/rails_ai_context/introspectors/interaction.rb +127 -0
  10. data/lib/rails_ai_context/introspectors/job_introspector.rb +18 -1
  11. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +34 -0
  12. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +15 -0
  13. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +110 -20
  14. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +11 -2
  15. data/lib/rails_ai_context/introspectors/model_introspector.rb +42 -10
  16. data/lib/rails_ai_context/introspectors/route_introspector.rb +49 -16
  17. data/lib/rails_ai_context/introspectors/schema_introspector.rb +30 -0
  18. data/lib/rails_ai_context/introspectors/superclass_chain.rb +98 -0
  19. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +26 -0
  20. data/lib/rails_ai_context/payload.rb +10 -1
  21. data/lib/rails_ai_context/resources.rb +1 -1
  22. data/lib/rails_ai_context/serializers/markdown_serializer.rb +4 -3
  23. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +4 -2
  24. data/lib/rails_ai_context/tools/analyze_feature.rb +30 -2
  25. data/lib/rails_ai_context/tools/base_tool.rb +28 -0
  26. data/lib/rails_ai_context/tools/dependency_graph.rb +17 -10
  27. data/lib/rails_ai_context/tools/diagnose.rb +31 -0
  28. data/lib/rails_ai_context/tools/generate_test.rb +31 -24
  29. data/lib/rails_ai_context/tools/get_concern.rb +103 -1
  30. data/lib/rails_ai_context/tools/get_config.rb +8 -2
  31. data/lib/rails_ai_context/tools/get_context.rb +31 -13
  32. data/lib/rails_ai_context/tools/get_controllers.rb +3 -1
  33. data/lib/rails_ai_context/tools/get_engines.rb +9 -6
  34. data/lib/rails_ai_context/tools/get_env.rb +58 -15
  35. data/lib/rails_ai_context/tools/get_job_pattern.rb +44 -13
  36. data/lib/rails_ai_context/tools/get_model_details.rb +36 -9
  37. data/lib/rails_ai_context/tools/get_routes.rb +39 -5
  38. data/lib/rails_ai_context/tools/get_schema.rb +38 -4
  39. data/lib/rails_ai_context/tools/get_service_pattern.rb +85 -41
  40. data/lib/rails_ai_context/tools/get_view.rb +53 -19
  41. data/lib/rails_ai_context/tools/onboard.rb +15 -6
  42. data/lib/rails_ai_context/tools/search_code.rb +10 -4
  43. data/lib/rails_ai_context/tools/security_scan.rb +243 -53
  44. data/lib/rails_ai_context/tools/validate_semantics.rb +7 -0
  45. data/lib/rails_ai_context/version.rb +1 -1
  46. data/lib/rails_ai_context/vfs.rb +25 -1
  47. data/server.json +1 -1
  48. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78848e75315236681b6b31bba337665e75a143d862d2d1ff828f48f50b1756f5
4
- data.tar.gz: 8af5164b8ee4ea9900d82450d2a74905dcf1b831c02d7ba95c5c869274c18e9e
3
+ metadata.gz: f036023367c149fd14eb8bc96106167cbd0c4ec7f042673930c74472a7caf44e
4
+ data.tar.gz: 1289481c7e5749263b762ebb9cc41a25504b4355896fd2215d2916e669132b34
5
5
  SHA512:
6
- metadata.gz: 6dbfa116e75a4675314e6c4a06ada6c2c7d7b8c7f0d9fb5ca0abe9945add65f1729b0101200debf5c25ea2190e66b57f41761b4307d8414d77ccfc62dc292e9d
7
- data.tar.gz: 86984bc5566480da28d88b7ef475138bb85c9ba459fcd87e58f8a4afd03239a365ee30d032412701291ec0bd60f7f0e0cccfa882fee0150e6baed74c966a33b1
6
+ metadata.gz: 5ad0b4e2f8096894ceec5a5c9989472173e8fabd98c38b1e9fc5e4e0ac3b7d90ad49473d6808f592f5d5b0c6ca9c6a897d7d2d6d79a064bbff0b6a44da19a0aa
7
+ data.tar.gz: ea624bf293f2e40a49d0d62579ca10c224200ab9e0f6681cdeb3f62955a599024a9ced4179f701f907d0b6ab9c55d5240ba595243a428b7b3920d47aade0b8d2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,185 @@ 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.29.0] - 2026-09-23
9
+
10
+ Twenty-three QA reports against v5.27.0, each one a wrong answer a reader
11
+ could act on, plus what eight review rounds found in the fixes themselves.
12
+
13
+ ### Added
14
+
15
+ - **Model payloads carry method counts and the model's own methods
16
+ uncapped.** `instance_method_count`, `class_method_count` and
17
+ `source_instance_methods` sit beside the capped `instance_methods` and
18
+ `class_methods` lists, on both tiers, so a reader can tell a cut list from
19
+ a whole one.
20
+ - **The schema payload carries `declared_tables`**, what `db/schema.rb`
21
+ declares, on both tiers: nil for an app whose tables come from
22
+ `structure.sql` or the migrations.
23
+
24
+ ### Changed
25
+
26
+ - **"Mounted Engines" is "Mounted Apps" everywhere it is printed**: the
27
+ `engines`, `routes` and `onboard` tools, the generated context files, and
28
+ the MCP `rails://engines` resource name. The stack overview line reads
29
+ `Mounted:` rather than `Engines:`. Half of what the list holds are plain
30
+ Rack apps. The payload key stays `mounted_engines`.
31
+ - **A mount whose path the source does not spell out carries `path: nil`**
32
+ rather than the string `"unknown"`, and is printed without a path.
33
+ - **`routes` reads a blank `controller` as no filter**, and a name that
34
+ normalizes to nothing (`_controller`) as matching nothing, where the empty
35
+ string used to match every route key.
36
+
37
+ ### Fixed
38
+
39
+ - **A Sidekiq worker answers to its own name, and the bracket carries the
40
+ limit that governs it.** `job:"Billing::Invoices::CreateWorker"` answered
41
+ "No jobs found" for a worker the same tool had just listed, because the
42
+ single-job lookup read the ActiveJob list only. It reads both lists now, and
43
+ a name in neither is a not-found that names what exists. The listing prints
44
+ its "workers the introspector did not see" caveat whenever it prints
45
+ workers, rather than only when `config/sidekiq.yml` happens to exist, and a
46
+ `sidekiq_throttle` prints under the worker it throttles - 438 of 522 workers
47
+ on one app declared one and none of them showed it.
48
+ - **onboard's async section reads the workers out of the hash it already
49
+ had.** The section counted jobs, mailers and channels, so an app whose
50
+ background work is 522 Sidekiq workers read as "4 mailers." and the section
51
+ disappeared entirely when workers were the only async code. The "not
52
+ covered" line no longer prints next to a worker list it contradicts.
53
+ - **What an ActiveInteraction declares is read in one place.** A filter
54
+ declared inside another filter's block (`string :title` inside `hash
55
+ :order_params do`) is a key of that hash, not an input of the class:
56
+ `service_pattern` showed four inputs where `.filters` has two, and
57
+ `generate_test` passed the other two to `.run`, which drops them silently. A
58
+ subclass of the app's own base interaction is an interaction too, with its
59
+ parent's filters first, so `generate_test` stops emitting `.call`, which
60
+ `ActiveInteraction::Base` does not define. `GenericMacroListener` records
61
+ each call's own offset and its enclosing call's, which is what both tools
62
+ pair filters by.
63
+ - **generate_test names the constant the file declares.** A path camelizes
64
+ through Ruby's inflector, which has not read the app's
65
+ `config/initializers/inflections.rb` on the static tier, so
66
+ `ai_reports/build.rb` gave `AiReports::Build` where the app defines
67
+ `AIReports::Build` - a constant nothing defines, in a spec that dies on
68
+ load.
69
+ - **service_pattern looks for callers where the app keeps code.** The scan
70
+ named six `app/` directories, so a caller in `app/tools` or under `lib/` was
71
+ invisible. It reads every `app/` and `lib/` tree, plus whatever else a
72
+ booted app autoloads from, and says when the twenty-entry cap or its own
73
+ file ceiling left the list partial.
74
+ - **schema tells a missing migration from a typo.** Booted, a table declared
75
+ in `db/schema.rb` and absent from the connected database answered "Did you
76
+ mean 'comments'?". The payload carries the declared tables beside the live
77
+ ones, so the answer names the migration that has not run, and the listing
78
+ header says the two counts disagree instead of pairing a live table count
79
+ with the file's version stamp.
80
+ - **A validator under app/models/concerns is not a concern.** The type came
81
+ from the directory alone, so 37 `ActiveModel::Validator` subclasses on one
82
+ app were listed as model concerns used by nothing. They are listed as
83
+ validators - following the app's own validator base class, not one level of
84
+ compare - and looked up by the `validates_with`, or the validation option,
85
+ that wires them, in a model or in a concern's `included` block. The keys
86
+ `validates` reads for itself (`on:`, `if:` and the like) name no
87
+ validator.
88
+ - **dependency_graph counts both header numbers over the same models.** The
89
+ model count was app-wide and the association count covered the fifty nodes
90
+ that survived the cap. Both are app-wide now, and the truncation note says
91
+ how many of the associations the cut graph draws.
92
+ - **get_context reads the views Rails would resolve.** It handed `GetView`
93
+ the last segment of the controller path, so `Api::V1::Admin::OrdersController`
94
+ picked up `app/views/orders`, a directory of templates a background service
95
+ renders. The answer names the directory its views came from, and a
96
+ flat-directory fallback is labelled as one.
97
+ - **analyze_feature finds a test by its path.** A spec whose feature word is a
98
+ directory (`spec/services/billing/invoices/create_spec.rb`) was dropped,
99
+ while the gap checker beside it already matched on the path. The suite's
100
+ own words stay out of the match - the `spec/` root, the type directory it
101
+ files a test under (`models/`, `requests/`), and the `_spec` suffix - so
102
+ `--feature models` is not every model spec.
103
+ - **routes answers an exact controller key with its own routes.** A substring
104
+ filter returned a nested sibling's routes too (`api/v1/admin/orders` swept
105
+ in `api/v1/admin/orders/ai_data`), and `get_context` inherited it. A short
106
+ name still matches every controller that carries it.
107
+ - **A template at the root of app/views is listed.** Its filename became a
108
+ directory group that matched nothing, so the header counted a file the body
109
+ never printed, and the controller-miss hint suggested a directory that does
110
+ not exist.
111
+ - **A word in a quoted string is not an instance variable.** `view` reported
112
+ a chat handle inside a Ruby string literal as a template's ivar; the reader
113
+ strips string literals and keeps interpolation, in ERB tags and in the
114
+ Ruby template handlers (Jbuilder, Builder, `.ruby`), and `get_view`'s
115
+ hydrator reads through the same method. Whichever quote opens first owns
116
+ the literal, so an apostrophe inside `"Don't"` does not swallow the ivar
117
+ beside it.
118
+ - **env_config tells a re-assignment from a tuple.** Two unconditional
119
+ assignments of one key rendered as `:file, :test`, which reads exactly like
120
+ `:mem_cache_store, { pool_size: 5 }`. The winner is named, with what it
121
+ overrode.
122
+ - **env keeps one default per call site.** One label for every site said a
123
+ variable was optional while one of its reads was `ENV.fetch` with no
124
+ default, which raises `KeyError`. A fetch whose fallback is an expression
125
+ says its default is computed at runtime rather than claiming it has none,
126
+ and an `ENV["X"]` read says it is nil when unset, apart from the fetch
127
+ that raises.
128
+ - **A Rack app attached with `match ... to:` is found.** `mount` is that call
129
+ with a name derived, and the exact-path form is what an app writes when an
130
+ unanchored mount would swallow a sibling path. It reaches `engines` and
131
+ `routes`, which names the mounted apps it counts instead of calling them
132
+ engine mounts, and the booted tier lists every Rack endpoint rather than
133
+ `Rails::Engine` subclasses alone. A mount inside a `namespace` or `scope`
134
+ carries that prefix; one whose enclosing scope, or its `path:`, is an expression is listed
135
+ with no path rather than an unprefixed one, and every list prints it
136
+ without one. `engines` follows every file `config/routes.rb` draws,
137
+ through the walk the static `routes` answer uses, so on the static tier
138
+ the two name one set of mounted apps; booted, `routes` reads the live
139
+ route table. `engines`, `onboard`, the MCP resource and the
140
+ generated context files head the same list "Mounted Apps", because half of
141
+ what it holds are not engines. On the static tier a `match ... to: SomeApp`
142
+ is counted once, as the mount it is, rather than also as a construct the
143
+ walk could not expand, and so are `get "/status" => StatusApp` and
144
+ `mount ActionCable.server => "/cable"`, which the walk did not see at all.
145
+ - **config calls a zero-byte initializer empty** rather than "all commented
146
+ out".
147
+ - **Smaller corrections in the same pass.** `validate_semantics` asks the
148
+ loaded model class before calling a callback method missing, and on the
149
+ static tier makes no claim once the payload's method list was cut at its
150
+ cap, so an inherited method past the cap is no longer reported as missing.
151
+ `get_controllers` points
152
+ `rails_get_view` at the controller's full path rather than its last segment,
153
+ which is the directory Rails resolves. The stack overview's `Engines:` line
154
+ is `Mounted:`, because half of what it lists are plain Rack apps.
155
+ - **diagnose stops reading a display cap as a model's whole interface.** A
156
+ method past the thirtieth was reported as not existing, in the same answer
157
+ whose Method Trace printed its definition. Booted, diagnose asks the loaded
158
+ model class, which knows a concern's methods and a gem's as well as the
159
+ model's own. Statically, the model's own methods travel uncapped beside the
160
+ capped display list, and where a concern or a parent could define the
161
+ method the answer declines rather than guesses. `model_details` says how
162
+ many of the model's methods it is showing.
163
+ - **A CamelCase controller name resolves everywhere.** The needle was
164
+ downcased without being underscored, so "GiftCards" never equalled the
165
+ route key's own "gift_cards": `Payload.find_controller` missed it, and every
166
+ tool that resolves a controller through it missed it too. One normalization
167
+ now serves the payload, the routes tool and the MCP resource, and the
168
+ resource answers a name that resolves to nothing with an error naming what
169
+ exists rather than a zero-route success document.
170
+ - **security_scan runs the brakeman the machine has.** The app's bundle
171
+ narrows the load path, so a machine with brakeman installed was told to add
172
+ it to the Gemfile while the other tier scanned the same app. When the
173
+ in-process require fails and the gem is installed, the scan runs it as its
174
+ own process outside the bundle - reading the report from a file of its own,
175
+ since a gem manager's binstub can print to stdout first - and renders the
176
+ result the same way, with a line saying which brakeman answered and from
177
+ where. When the outside run writes no report, the answer carries the last
178
+ line brakeman printed about why. With no brakeman anywhere, the message
179
+ says that instead of guessing, and the availability answer is keyed by
180
+ tier rather than decided once per process.
181
+ - **An exact search with a space keeps its context lines on ripgrep 13.**
182
+ The literal was escaped with Ruby's `\ `, which ripgrep 13 (Ubuntu 22.04,
183
+ Debian 12) rejects, so the search fell back to the Ruby scan and dropped
184
+ context lines and files with no listed extension. The space goes through
185
+ unescaped now, which both engines read the same way.
186
+
8
187
  ## [5.28.0] - 2026-09-22
9
188
 
10
189
  Thirty-six changes an architecture survey of v5.27.0 asked for, nine of them
data/CONTEXT.md CHANGED
@@ -88,6 +88,43 @@ Three senses inside the gem, and the payload one is wider than either everyday R
88
88
 
89
89
  The callable interface of a class as this gem reports it: the class's own public instance methods - a class nested in the same file is a separate owner, not part of the interface - minus framework-shaped `_` names, read source-first, with reflection minus the app-owned base as the honest fallback. `ActionResolver` is the one answer; controller and mailer are configurations of it, and a channel's "stream methods" are a narrower selection of the same reading.
90
90
 
91
+ ## Mounted app
92
+
93
+ A Rack app the routing table attaches at a path, engine or not. `mount App =>
94
+ "/path"` is `match("/path", to: App, via: :all, anchor: false)` with a name
95
+ derived, so both spellings build the same endpoint and both are this. The
96
+ payload key is `mounted_engines` for the sections that predate the widening;
97
+ what it holds is every controller-less, non-dynamic endpoint the route set
98
+ carries, which is what the count beside it has always counted.
99
+
100
+ Distinct from a **Rails engine**, which is a `Rails::Engine` subclass whether
101
+ or not anything mounts it, and which `rails_get_engines` lists separately under
102
+ its loaded classes. The record key is `engine:` for the same reason the section
103
+ key is `mounted_engines:` - it predates the widening, and what it holds is the
104
+ mounted app's constant, engine or not.
105
+
106
+ ## Interaction filter
107
+
108
+ What an ActiveInteraction service declares as its interface, and not the
109
+ **filter chain** above, which is controllers. `Interaction` is the one answer
110
+ for both tools that read it (`rails_get_service_pattern`,
111
+ `rails_generate_test`), because a static answer that differs from the booted
112
+ one is the divergence the module exists to end.
113
+
114
+ **A nested filter is not an input.** `string :title` inside `hash
115
+ :order_params do ... end` is a key of that hash: it never reaches `.filters`,
116
+ and handing it to `.run` as a keyword argument is dropped without a word. So
117
+ nesting is kept on the record rather than flattened, and only the top level is
118
+ an input.
119
+
120
+ **The chain is the class's, not the file's.** An interaction is often a
121
+ subclass of the app's own base interaction rather than of
122
+ `ActiveInteraction::Base`, and its filters are then the parent's first and its
123
+ own after, which is the order `.filters` answers in. Following that chain needs
124
+ the parent's source, which only the caller can find, so it arrives as
125
+ `lookup` - a callable from a constant name to that class's source, and nil to
126
+ stop at the one file.
127
+
91
128
  ## Filter chain
92
129
 
93
130
  Which filters a controller runs, and which of them a given action runs. `ActionFilters` is the one answer, and every controller surface reads its filter line from there, so no two answers can disagree about what a class inherits or skips. `for_controller` answers about the class; `for` answers about one of its actions. Both return `own`, `inherited` and `skipped`.
@@ -126,7 +126,7 @@ end
126
126
 
127
127
  | Introspector | Key | What it extracts |
128
128
  |:-------------|:----|:-----------------|
129
- | JobIntrospector | `:jobs` | Background jobs, Sidekiq workers under `app/workers`, and mailers: queue, retries, `sidekiq_options`, schedules, and the `file:` each one is defined in |
129
+ | JobIntrospector | `:jobs` | Background jobs, Sidekiq workers under `app/workers`, and mailers: queue, retries, `sidekiq_options`, any `sidekiq_throttle`, schedules, and the `file:` each one is defined in |
130
130
  | RakeTaskIntrospector | `:rake_tasks` | Custom rake tasks |
131
131
 
132
132
  ### Security & Auth
@@ -193,7 +193,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
193
193
 
194
194
  | Listener | What it detects |
195
195
  |:---------|:---------------|
196
- | GenericMacroListener | Any receiver-less macro you name: `GenericMacroListener.new(:devise, :rate_limit)`. Returns args, values (with a source-slice fallback), options, option values and option nodes |
196
+ | GenericMacroListener | Any receiver-less macro you name: `GenericMacroListener.new(:devise, :rate_limit)`. Returns args, values (with a source-slice fallback), options, option values and option nodes, 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 |
197
197
  | ChainedCallListener | Calls on a receiver: `ChainedCallListener.new(:includes)`, or `receiver: :inflect` to pin the receiver. Reports the receiver name |
198
198
  | ConfigAssignmentListener | `config.key = value` and `config.a.b = value` in initializers and `config/environments/*.rb`, plus bare `config.jwt do ... end` section references. Takes a root name (`:config` by default, e.g. `:DatabaseCleaner`) |
199
199
  | ClassDefinitionListener | Class definitions with their superclass, namespaces resolved |
@@ -202,7 +202,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
202
202
  | SchemaDslListener | `schema.rb`: `create_table`, `t.string`, `t.index`, `add_foreign_key`, `create_enum` |
203
203
  | MigrationDslListener | Migration DSL: `create_table`, `add_column`, `add_index`, `add_reference`, and friends |
204
204
  | RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes; routing concerns (`concern` definitions replayed at each `concerns:` site), `with_options` defaults merged under each inner call, and the `as:`, `param:`, `module:`, `path:` and `only:`/`except:` options |
205
- | MountListener | `mount Sidekiq::Web, at: "/sidekiq"` and the hash form |
205
+ | 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 |
206
206
  | GemfileDslListener | `gem "name", "version"` and `group :development do ... end` |
207
207
  | RakeTaskDslListener | `namespace`, `desc`, `task` in `.rake` files |
208
208
  | EnvAccessListener | `ENV["KEY"]`, `ENV.fetch("KEY")`, `ENV.fetch("KEY", default)` |
@@ -233,6 +233,19 @@ Regex is the right tool, and stays, for:
233
233
 
234
234
  Every remaining regex over `.rb` content carries a one-line comment saying which of these it is. If you add one without a reason, convert it instead.
235
235
 
236
+ ### Readers built on the listeners
237
+
238
+ Some questions take more than one walk to answer, and the answer has to be the
239
+ same wherever it is asked. Those live as their own modules under
240
+ `Introspectors/`, and a tool calls one rather than repeating the walk:
241
+
242
+ | Module | What it answers |
243
+ |:-------|:---------------|
244
+ | `DeclaredConstant` | The constant a source file calls its own class, against the one its path camelizes to |
245
+ | `TableName` | The table a model reads, from its own declarations |
246
+ | `SuperclassChain` | What a class inherits from, followed through the app's own sources: the chain from a file's class up to a named base, and the constant-to-source lookup over the app's autoload roots that walks it |
247
+ | `Interaction` | Whether a class runs as an ActiveInteraction, following its superclass chain through the app's own sources, and the filters it takes - inherited ones first, one per name, each carrying the filters nested inside its block. See the **Interaction filter** entry in `CONTEXT.md` |
248
+
236
249
  ### Confidence tagging
237
250
 
238
251
  Every AST result carries a confidence tag:
data/docs/TOOLS.md CHANGED
@@ -116,7 +116,7 @@ Full-stack feature analysis: models + controllers + routes + services + jobs + v
116
116
 
117
117
  ### `rails_get_context`
118
118
 
119
- Composite context: schema + model + controller + routes + views for a resource.
119
+ Composite context: schema + model + controller + routes + views for a resource. Views come from the directory Rails resolves for the controller; a flat-directory fallback is labelled.
120
120
 
121
121
  | Parameter | Type | Default | Description |
122
122
  |:----------|:-----|:--------|:------------|
@@ -140,7 +140,10 @@ Narrative app walkthrough for getting up to speed.
140
140
 
141
141
  ### `rails_get_schema`
142
142
 
143
- Database schema with column types, indexes, defaults, encrypted hints.
143
+ Database schema with column types, indexes, defaults, encrypted hints. Booted,
144
+ a table `db/schema.rb` declares and the connected database does not have is
145
+ named as a migration that has not run, and the listing header says when the
146
+ two table counts disagree.
144
147
 
145
148
  | Parameter | Type | Default | Description |
146
149
  |:----------|:-----|:--------|:------------|
@@ -149,7 +152,7 @@ Database schema with column types, indexes, defaults, encrypted hints.
149
152
 
150
153
  ### `rails_get_model_details`
151
154
 
152
- AST-parsed model internals. Every result carries `[VERIFIED]` or `[INFERRED]` confidence tag.
155
+ AST-parsed model internals. Every result carries `[VERIFIED]` or `[INFERRED]` confidence tag. The method list says how many of the model's methods it is showing.
153
156
 
154
157
  | Parameter | Type | Default | Description |
155
158
  |:----------|:-----|:--------|:------------|
@@ -176,7 +179,10 @@ its concerns), not the order Rails registered them in.
176
179
 
177
180
  ### `rails_get_concern`
178
181
 
179
- Concern methods, source code, and which models include it.
182
+ Concern methods, source code, and which models include it. A class under
183
+ `app/models/concerns` that subclasses `ActiveModel::Validator` is listed as a
184
+ validator rather than a concern, and its users are the models that name it in
185
+ `validates_with`.
180
186
 
181
187
  | Parameter | Type | Default | Description |
182
188
  |:----------|:-----|:--------|:------------|
@@ -201,7 +207,11 @@ Controller actions with inherited filters, render map, strong params. Includes s
201
207
 
202
208
  ### `rails_get_routes`
203
209
 
204
- Routes with code-ready helpers (`post_path(@record)`) and required params.
210
+ Routes with code-ready helpers (`post_path(@record)`) and required params. A
211
+ fully qualified controller key answers with its own routes only; a short name
212
+ still matches every controller that carries it. Rack apps attached with `mount`
213
+ or `match ... to:` are named with the path they answer on, when the source
214
+ spells one out.
205
215
 
206
216
  | Parameter | Type | Default | Description |
207
217
  |:----------|:-----|:--------|:------------|
@@ -216,7 +226,7 @@ Routes with code-ready helpers (`post_path(@record)`) and required params.
216
226
 
217
227
  ### `rails_get_view`
218
228
 
219
- View templates with instance variables, Turbo frames, Stimulus controllers, partial locals. Includes schema hints for detected ivars.
229
+ View templates with instance variables, Turbo frames, Stimulus controllers, partial locals. Includes schema hints for detected ivars. A template directly under `app/views` is grouped as `(app/views root)`, which `path:` reaches and `controller:` does not.
220
230
 
221
231
  | Parameter | Type | Default | Description |
222
232
  |:----------|:-----|:--------|:------------|
@@ -299,7 +309,9 @@ Brakeman static analysis: SQL injection, XSS, mass assignment, command injection
299
309
  |:----------|:-----|:--------|:------------|
300
310
  | `detail` | enum | `standard` | `summary`, `standard`, `full` |
301
311
 
302
- > Requires the `brakeman` gem. Gracefully reports "not installed" if missing.
312
+ > Requires the `brakeman` gem. When it cannot be loaded, the answer says which
313
+ > case it is: brakeman is nowhere on the machine, or it is installed and the
314
+ > app's bundle does not carry it, which `--no-boot` scans around.
303
315
 
304
316
  ### `rails_performance_check`
305
317
 
@@ -349,7 +361,7 @@ Notable gems with versions, categories, and config file locations.
349
361
 
350
362
  ### `rails_get_env`
351
363
 
352
- Environment variables + credentials keys (values are never exposed). Scans `.rb`, `.rake`, ERB views and config YAML under `app`, `config` and `lib`; files matching `sensitive_patterns` (`config/database.yml`, credentials, keys) are never read, and the answer says so.
364
+ Environment variables + credentials keys (values are never exposed). Scans `.rb`, `.rake`, ERB views and config YAML under `app`, `config` and `lib`; files matching `sensitive_patterns` (`config/database.yml`, credentials, keys) are never read, and the answer says so. A variable whose call sites pass different defaults is labelled as such rather than with one site's default; `detail:"full"` names each site's.
353
365
 
354
366
  | Parameter | Type | Default | Description |
355
367
  |:----------|:-----|:--------|:------------|
@@ -365,7 +377,12 @@ Application and framework helpers with view cross-references.
365
377
 
366
378
  ### `rails_get_service_pattern`
367
379
 
368
- Service object interface, dependencies, side effects, callers.
380
+ Service object interface, dependencies, side effects, callers. An
381
+ ActiveInteraction's inputs include the ones it inherits, with the filters
382
+ nested inside a `hash` filter shown under it. Callers are read from every
383
+ `app/` and `lib/` tree, and on a booted app from any other directory it
384
+ autoloads, and the page says when the twenty-caller display cap or the scan's
385
+ own file ceiling left the list partial.
369
386
 
370
387
  | Parameter | Type | Default | Description |
371
388
  |:----------|:-----|:--------|:------------|
@@ -376,7 +393,9 @@ Service object interface, dependencies, side effects, callers.
376
393
 
377
394
  Background job queue, retries, guard clauses, broadcasts, schedules. Sidekiq
378
395
  workers under `app/workers` are listed alongside the ActiveJob jobs, with
379
- their `sidekiq_options` and `perform` signature.
396
+ their `sidekiq_options`, any `sidekiq_throttle`, and the `perform` signature. A
397
+ worker that inherits its Sidekiq mixin from a base worker is one of them, and
398
+ `job:` answers a worker name as well as a job name.
380
399
 
381
400
  | Parameter | Type | Default | Description |
382
401
  |:----------|:-----|:--------|:------------|
@@ -414,7 +433,7 @@ ActionMailer mailers: every mailer class with its delivery actions and delivery
414
433
 
415
434
  ### `rails_get_engines`
416
435
 
417
- Rails engines: engines mounted in `config/routes.rb` (with known-engine descriptions) and loaded engine classes with route/model counts.
436
+ What `config/routes.rb` mounts - engines and plain Rack apps alike, with known-engine descriptions, each with the path it answers on when the source spells one out - and loaded engine classes with route/model counts.
418
437
 
419
438
  *No parameters.*
420
439
 
@@ -506,7 +525,7 @@ Reverse file tail with level filtering and sensitive data redaction.
506
525
 
507
526
  ### `rails_diagnose`
508
527
 
509
- One-call error diagnosis with classification, context, git blame, and log correlation.
528
+ One-call error diagnosis with classification, context, git blame, and log correlation. It does not call a method undefined when the model's method list could be missing one - a concern's, a parent's, or anything past the payload's own cap.
510
529
 
511
530
  | Parameter | Type | Default | Description |
512
531
  |:----------|:-----|:--------|:------------|
@@ -256,6 +256,19 @@ bundle add brakeman --group development
256
256
 
257
257
  Without it, the tool reports "not installed" but the gem works fine otherwise.
258
258
 
259
+ ### "Installed on this machine but not in this app's bundle"
260
+
261
+ The scan runs under the app's own bundle, so a brakeman installed globally is
262
+ not on its load path. The tool falls back on its own: it runs the installed
263
+ brakeman as a separate process outside the bundle and says so under the
264
+ results. This message means that fallback produced no report either - the gem
265
+ is there and the run failed. Run `brakeman` in the app directory to see what
266
+ it hit, or add it to the Gemfile so the scan runs in-process:
267
+
268
+ ```bash
269
+ bundle add brakeman --group development
270
+ ```
271
+
259
272
  ---
260
273
 
261
274
  ## Performance issues
@@ -66,21 +66,16 @@ module RailsAiContext
66
66
 
67
67
  private
68
68
 
69
- def root
70
- app.root.to_s
71
- end
72
-
69
+ # What config/routes.rb and the files it draws mount, on both tiers,
70
+ # through the route introspector's own walk: on the static tier the
71
+ # routes section reads the same walk, and booted it reads the live
72
+ # route table, which also holds what a gem mounts for itself.
73
73
  def discover_mounted_engines
74
- routes_path = File.join(root, "config/routes.rb")
75
- return [] unless File.exist?(routes_path)
76
-
77
- ast_data = SourceIntrospector.walk(routes_path, { mounts: -> { Listeners::MountListener.new } })
78
74
  engines = []
79
75
 
80
- ast_data[:mounts].each do |mount|
76
+ RouteIntrospector.new(app).static_mounts.each do |mount|
81
77
  engine_name = mount[:engine]
82
- path = mount[:path] || "unknown"
83
- info = { engine: engine_name, path: path }
78
+ info = { engine: engine_name, path: mount[:path] }
84
79
  known = KNOWN_ENGINES[engine_name]
85
80
  if known
86
81
  info[:category] = known[:category].to_s
@@ -98,17 +98,39 @@ module RailsAiContext
98
98
  end
99
99
 
100
100
  # `:memory_store if Rails.root.join(...).exist?, else :null_store`.
101
+ #
102
+ # Two unconditional assignments of one key are not branches: Rails runs
103
+ # both lines and the last one wins. Comma-joining them read exactly like
104
+ # a single assignment whose value is a comma list
105
+ # (`:mem_cache_store, { pool_size: 5 }`), so the winner is named and the
106
+ # assignment it overrode is named after it.
101
107
  def branch_values(entries)
102
108
  return one_line(entries.first[:source].to_s) if entries.size == 1
103
109
 
104
- entries.map do |entry|
110
+ unconditional, conditional = entries.partition { |entry| entry[:condition].nil? }
111
+ rendered = conditional.map { |entry|
105
112
  value = one_line(entry[:source].to_s)
106
- case entry[:condition]
107
- when nil then value
108
- when "else" then "else #{value}"
109
- else "#{value} if #{entry[:condition]}"
110
- end
111
- end.uniq.join(", ")
113
+ text = entry[:condition] == "else" ? "else #{value}" : "#{value} if #{entry[:condition]}"
114
+ [ entry[:location].to_i, text ]
115
+ }
116
+
117
+ if unconditional.any?
118
+ # The last line the file runs is the value in force, whatever ran
119
+ # before it, so the winner is taken before any de-duplication - a
120
+ # value that repeats is still the one that ran last.
121
+ winner = unconditional.last
122
+ overridden = unconditional[0..-2].map { |entry| one_line(entry[:source].to_s) }.uniq
123
+ overridden -= [ one_line(winner[:source].to_s) ]
124
+ text = one_line(winner[:source].to_s)
125
+ text += " (overrides #{overridden.join(', ')})" if overridden.any?
126
+ rendered << [ winner[:location].to_i, text ]
127
+ end
128
+
129
+ # Source order, because that is run order: a conditional assignment
130
+ # printed after the unconditional one that follows it reads as the
131
+ # value in force.
132
+ rendered.each_with_index.sort_by { |(line, _), index| [ line, index ] }
133
+ .map { |(_, text), _| text }.uniq.join(", ")
112
134
  end
113
135
 
114
136
  # The booted app has already resolved the branch, and two tools reading
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # What an ActiveInteraction service declares as its interface: whether the
6
+ # class is one, and the filters it takes.
7
+ #
8
+ # Two things the filter macros alone do not say, and every consumer needs.
9
+ #
10
+ # A filter declared inside another filter's block is a key of that filter,
11
+ # not an input of the class: `string :title` inside `hash :order_params do
12
+ # ... end` never reaches `.filters`, and passing it to `.run` drops it
13
+ # silently. So nesting is kept, never flattened.
14
+ #
15
+ # An interaction is often a subclass of the app's own base interaction
16
+ # rather than of ActiveInteraction::Base, and its filters are then its own
17
+ # plus the ones it inherits, parent's first, which is the order
18
+ # `.filters` answers in. Following that chain needs the parent's source,
19
+ # which only the caller can find, so it comes in as `lookup` - a callable
20
+ # from a constant name to that class's source, or nil to stop at the one
21
+ # file. `SuperclassChain.lookup_for` builds the one both consumers pass.
22
+ #
23
+ # Reading the loaded constant's `.filters` would answer both on a booted
24
+ # app, but the two consumers (rails_get_service_pattern, rails_generate_test)
25
+ # answer from source in both tiers, and a static answer that differs from
26
+ # the booted one is the divergence this module exists to end.
27
+ module Interaction
28
+ BASE = "ActiveInteraction::Base"
29
+
30
+ # The filter macros ActiveInteraction defines. `interface` and `record`
31
+ # included: they are filters like any other.
32
+ FILTERS = %w[
33
+ array boolean date date_time decimal file float hash integer
34
+ interface object record string symbol time
35
+ ].freeze
36
+
37
+ # `nested` is the filters declared inside this one's block; `declared_by`
38
+ # the class that declares it, which is not this file's class for an
39
+ # inherited one.
40
+ Filter = Data.define(:macro, :name, :options, :declared_by, :nested)
41
+
42
+ module_function
43
+
44
+ # @param source [String] the file's source
45
+ # @param lookup [#call, nil] constant name -> that class's source
46
+ # @return [Boolean] whether the class this source declares runs as an
47
+ # ActiveInteraction
48
+ def interaction?(source, lookup: nil)
49
+ chain(source, lookup: lookup).any?
50
+ end
51
+
52
+ # The filters the class takes, inherited ones first, each with the
53
+ # filters nested inside it.
54
+ #
55
+ # @return [Array<Filter>] empty when the source declares no interaction
56
+ def filters(source, lookup: nil)
57
+ interface(source, lookup: lookup) || []
58
+ end
59
+
60
+ # Both answers from one walk of the chain, for a caller that needs to
61
+ # know whether the class is an interaction and what it takes: nil when
62
+ # it is not one, the filters when it is - and an interaction with no
63
+ # filters answers [], which is not nil.
64
+ #
65
+ # @return [Array<Filter>, nil]
66
+ def interface(source, lookup: nil)
67
+ links = chain(source, lookup: lookup)
68
+ return nil if links.empty?
69
+
70
+ one_per_name(links.reverse.flat_map { |link| own_filters(link) })
71
+ end
72
+
73
+ # The classes from ActiveInteraction::Base down to this one, nearest
74
+ # first, or empty when the chain never reaches it.
75
+ #
76
+ # @return [Array<SuperclassChain::Link>]
77
+ def chain(source, lookup: nil)
78
+ SuperclassChain.to(source, bases: [ BASE ], lookup: lookup)
79
+ end
80
+
81
+ # `.filters` is a hash keyed by name, so a subclass that redeclares its
82
+ # parent's filter replaces it and keeps the parent's position. Rendering
83
+ # both printed the name twice, and generating `run(token: nil, token:
84
+ # nil)` from it is a duplicate keyword argument.
85
+ def one_per_name(filters)
86
+ slots = {}
87
+ filters.each_with_index do |filter, index|
88
+ existing = slots[filter.name]
89
+ slots[filter.name] = [ existing ? existing.first : index, filter ]
90
+ end
91
+ slots.values.sort_by(&:first).map(&:last)
92
+ end
93
+
94
+ # The filters one class declares, nested ones attached to the filter
95
+ # whose block they sit in, paired by the parent call's own offset in the
96
+ # source.
97
+ def own_filters(link)
98
+ records = SourceIntrospector.walk_source(
99
+ link.source, { filters: -> { Listeners::GenericMacroListener.new(FILTERS) } }
100
+ )[:filters] || []
101
+
102
+ # Children first, so a filter is built once, with what it holds. One
103
+ # macro call declares one filter that can take a block (`hash :a, :b
104
+ # do` is not a shape ActiveInteraction accepts), so the call's own
105
+ # offset names the parent of everything inside it.
106
+ children = records.group_by { |record| record[:parent_offset] }
107
+ build_filters(children[nil] || [], children, link.name)
108
+ end
109
+
110
+ def build_filters(records, children, declared_by)
111
+ records.flat_map do |record|
112
+ nested = build_filters(children[record[:offset]] || [], children, declared_by)
113
+ Array(record[:args]).map do |name|
114
+ Filter.new(
115
+ macro: record[:macro].to_s,
116
+ name: name.to_s,
117
+ options: record[:option_values] || {},
118
+ declared_by: declared_by,
119
+ nested: nested
120
+ )
121
+ end
122
+ end
123
+ end
124
+ private_class_method :chain, :own_filters, :build_filters, :one_per_name
125
+ end
126
+ end
127
+ end