coatepec 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddcc044bcc53590ba19d988b0db8ea0aea4efd7db102bd6718196e61ab9ab820
4
- data.tar.gz: 1b8b779b5bb59068a7fdd5fc90e7d44b900d50f1918e1d68877a9ccad24bf1e7
3
+ metadata.gz: 9e0e730ee65e046252297e502039ca8db8b17b4d1774564f79cb708dbe4500b2
4
+ data.tar.gz: 66d1afbd32ef8ba95de436e046d5b8b90452578a5a1ee0af2a442f7a14f0054d
5
5
  SHA512:
6
- metadata.gz: 56fce0755730ab151c81fb43f88a28d6e081b242c3e9bb31fac71578c290e864efbc5e5313702a4c015b95a661cdd601f1701979c98dbbdae1a0cfa06e0be2fa
7
- data.tar.gz: 4101b3595de972478ac414f3c019b413ec991cfe1d8ea0fbb251010fad6d699eaeb849818a1c4dbcfe813b47b41af220b68a7f971efe287dbbb8c598d8ff9763
6
+ metadata.gz: b5168b16140e5199c41a11c4e9a73efee643d2acf80405090a7b1ff0ba32ab94432b1e02b396ca5456ba879960025ef640434fcc265ca19147633e00d0b47a88
7
+ data.tar.gz: 209b126ecc16dadfacad123098e06bcb9d53649f7f3a8b2eca7555a6d098d35f7307cc81b38b015ea0891b49ef088bbe55c3f33ea2de243e14e0212322e68ded
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Add `rails_controller`: reports an `ActionController` controller's
6
+ actions, action callbacks, and included concerns. Admits
7
+ `ActionController::API` controllers as well as `ActionController::Base`
8
+ ones.
9
+ - Each action is cross-referenced against `Rails.application.routes`,
10
+ the same route table `rails_routes` reads: `actions[].routes` lists the
11
+ verb/path/route name reaching that action (`path` is Rails' raw route
12
+ spec, `(.:format)` suffix included, byte-identical to `rails_routes`'
13
+ own `path` for the same route); `unroutable_actions` lists action
14
+ methods no route reaches (probable dead code); `routes_without_action`
15
+ lists route action names the controller doesn't define -- a request to
16
+ one of those raises `AbstractController::ActionNotFound` in production,
17
+ making this the tool's most actionable output.
18
+ - `callbacks[]` reports each `before`/`after`/`around` filter's `only`/
19
+ `except` action restriction (an array, or `nil` if unrestricted -- `nil`
20
+ and `[]` are distinct and both preserved) and any remaining `if`/
21
+ `unless` condition (a symbol by name; a Proc reported as `"(block)"`,
22
+ never serialized directly, since `Proc#to_s` leaks the app's absolute
23
+ source path).
24
+ - `concerns` lists app-defined modules only, included directly or
25
+ inherited from a base class; framework modules are excluded.
26
+ - **Known limitations, both by design:** strong parameters
27
+ (`params.require(...).permit(...)`) are not reported -- they exist only
28
+ as code inside a method body, never as class metadata, and recovering
29
+ them would require source parsing, which this gem does not do. Only the
30
+ main app's route table is read, so a controller mounted inside an
31
+ engine has its actions reported as unroutable even where the engine's
32
+ own routes reach them -- the same boundary `rails_routes` already has.
33
+
3
34
  ## 0.6.0
4
35
 
5
36
  - Add `rails_spec_flaky_check`: runs a spec selection multiple times with
data/README.md CHANGED
@@ -130,6 +130,7 @@ a no-op.
130
130
  | `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot |
131
131
  | `rails_routes` | `query?`, `limit?` (1..200, default 50), `offset?` | Case-insensitive filter across name/verb/path/controller/action |
132
132
  | `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`) | ActiveRecord models only; columns, associations, validators, enums -- no row data |
133
+ | `rails_controller` | `name` (constant path, e.g. `WidgetsController` or `Admin::ReportsController`) | Actions, action callbacks, concerns, and the routes reaching each action -- no request dispatch |
133
134
  | `rails_spec_flaky_check` | `paths`, `example?`, `timeout_seconds?` (per round, 1..900), `runs?` (2..20, default 5) | Runs the selection `runs` times with a fresh random seed each round; reports examples whose status was inconsistent across runs |
134
135
 
135
136
  ### Example queries
@@ -157,6 +158,53 @@ a no-op.
157
158
  rather than introspecting it (a nonexistent constant raises `model_not_found`
158
159
  instead) -- the tool only ever reflects on `ActiveRecord::Base` descendants.
159
160
 
161
+ `rails_controller`:
162
+
163
+ - "What actions does WidgetsController define, and what routes reach them?" --
164
+ `rails_controller(name: "WidgetsController")` -- see `actions[].routes`,
165
+ each with `verb`, `path` (Rails' raw route spec, `(.:format)` suffix
166
+ included -- byte-identical to the same route's `path` from `rails_routes`),
167
+ and `route_name`.
168
+ - "Does this controller have dead code, or a route that will 500?" -- same
169
+ call -- see `unroutable_actions` (action methods no route reaches --
170
+ probably dead code) and `routes_without_action` (action names the route
171
+ table expects but the controller doesn't define -- a request to that route
172
+ raises `AbstractController::ActionNotFound` in production; this is the
173
+ tool's most actionable output).
174
+ - "What before/after/around filters run on this controller's actions, and
175
+ under what conditions?" -- same call -- see `callbacks[]`: `kind`
176
+ (`"before"`/`"after"`/`"around"`), `filter` (the method name, or `"(block)"`
177
+ for a Proc), `only`/`except` (arrays of action names the filter is
178
+ restricted to/excluded from, or `nil` if unrestricted -- `nil` and `[]` mean
179
+ different things, so both are preserved: `nil` means no `only:`/`except:`
180
+ was given at all, while `[]` means one *was* given but names no action this
181
+ controller actually defines -- e.g. a typo or an action that was since
182
+ removed -- so `only: []` never runs and `except: []` never skips), and
183
+ `if`/`unless` (any remaining conditional, by symbol name or `"(block)"`).
184
+ - "What concerns does this controller pull in?" -- same call -- see
185
+ `concerns`: app-defined modules only, whether included directly or
186
+ inherited from a base class; framework modules (`ActionController::Base`
187
+ and everything above it in the ancestor chain) are excluded.
188
+ - `rails_controller` admits `ActionController::API` controllers as well as
189
+ `ActionController::Base` ones. A malformed constant name raises
190
+ `invalid_controller_name`; a name that doesn't resolve raises
191
+ `controller_not_found`; a name that resolves but isn't an
192
+ `ActionController` descendant (a plain class, a model) raises
193
+ `not_action_controller`.
194
+
195
+ `rails_controller` has two deliberate limitations, matching a boundary
196
+ `rails_routes` already has:
197
+
198
+ - **Strong parameters are not reported.** `params.require(:widget).permit(:name,
199
+ :size)` exists only as code inside a private method body, never as
200
+ queryable class metadata -- the only way to recover a permit-list is to
201
+ parse source, which this gem does not do (see `ROADMAP.md`'s "Considered
202
+ and set aside" section for why source parsing is out of scope generally).
203
+ - **Only the main app's route table is read.** A controller mounted inside an
204
+ engine will have its actions reported under `unroutable_actions` even where
205
+ the engine's own routes reach them -- `rails_routes` has the identical
206
+ boundary today.
207
+
160
208
  `rails_spec_flaky_check`:
161
209
 
162
210
  - "Is this spec flaky?" -- `rails_spec_flaky_check(paths: ["spec/models/widget_spec.rb"])`
@@ -233,9 +281,10 @@ principle.
233
281
  Coatepec takes the opposite approach: there's no eval, console, or SQL
234
282
  tool to begin with. `rails_spec_run` only ever executes RSpec files that
235
283
  already exist under the app's own allowed spec roots, and `rails_routes`/
236
- `rails_model` only ever call structured, read-only Rails APIs
237
- (`Rails.application.routes.routes`, `ActiveRecord` reflection) -- never
238
- `eval`, `const_get` on unvalidated input, or arbitrary method dispatch. If
284
+ `rails_model`/`rails_controller` only ever call structured, read-only Rails
285
+ APIs (`Rails.application.routes.routes`, `ActiveRecord` reflection,
286
+ `ActionController` callback/action-method metadata) -- never `eval`,
287
+ `const_get` on unvalidated input, or arbitrary method dispatch. If
239
288
  you genuinely need a Rails console over MCP, Rails Active MCP is built for
240
289
  that; Coatepec is for teams who want an agent to run specs and read
241
290
  structure without ever handing it a REPL.
data/ROADMAP.md ADDED
@@ -0,0 +1,197 @@
1
+ # Roadmap
2
+
3
+ Ideas and known future work for Coatepec, roughly in the order they came up.
4
+ Nothing here is committed to a release; this is a place to write things down
5
+ before they're designed, not a promise.
6
+
7
+ ## Minitest support
8
+
9
+ Every current tool (`rails_spec_run`, `rails_spec_flaky_check`, and the
10
+ paused `rails_spec_profile` below) is built around RSpec: `Coatepec::Spec::Runner`
11
+ shells out to `bundle exec rspec`/forks an RSpec process, and
12
+ `Coatepec::Spec::PathPolicy` validates selectors against RSpec's own
13
+ `*_spec.rb` convention. None of that carries over to a Rails app using
14
+ Minitest instead.
15
+
16
+ The shape of the fix should mirror what already exists rather than
17
+ invent something new: a parallel `Coatepec::Minitest::Runner` (or
18
+ similarly named) implementing the same "validate selectors, build CLI
19
+ args, run via the platform strategy, return a structured result" contract
20
+ `Spec::Runner` already does, reusing `ForkStrategy`/`SpawnStrategy`/`GuardedForkStrategy`
21
+ as-is where their logic is genuinely test-framework-agnostic (they mostly
22
+ just fork/spawn a command and reap it — the RSpec-specific parts are
23
+ `Runner#build_args` and the `--format json` output parsing in
24
+ `Coatepec::Spec::Result`, both of which would need Minitest equivalents:
25
+ Minitest's own JSON/machine-readable reporter, or `minitest-reporters`
26
+ gem output, would need to be identified before designing that half).
27
+
28
+ **Framework auto-detection via the Gemfile is straightforward and doesn't
29
+ need new machinery** — `Coatepec::Worker::RailsRuntime#loaded_gem_names`
30
+ already exists and is exactly the mechanism `GuardedForkStrategy` uses
31
+ today to check for fork-unsafe gems, and `Coatepec::Spec::FactoryProfRunner`
32
+ (see below) uses to check for `test-prof`. The same `loaded_gem_names.include?("rspec-rails")`
33
+ vs. `.include?("minitest")` check (a Rails app's default `Gemfile` already
34
+ declares one or the other, sometimes both) is enough to route
35
+ `rails_spec_run` (or a to-be-decided `rails_test_run`, if the two
36
+ frameworks' capabilities diverge enough to warrant separate tool names
37
+ rather than one dispatching tool) to the right runner. `Coatepec::Spec::Runner#require_rspec!`
38
+ already anticipates the gap in spirit: it raises `:unsupported_test_framework`
39
+ today when `rspec-rails` isn't loadable, rather than assuming RSpec
40
+ unconditionally.
41
+
42
+ Open questions for whenever this gets designed properly:
43
+ - One tool name that dispatches by detected framework, or separate
44
+ `rails_spec_run`/`rails_minitest_run`-style tools? (Affects whether an
45
+ agent needs to know which framework a given app uses before calling the
46
+ right tool, vs. the tool figuring it out.)
47
+ - What Minitest gives you for structured per-example output
48
+ (pass/fail/pending, id, file/line) equivalent to RSpec's `--format json`
49
+ — needed before `rails_spec_flaky_check`'s per-example flaky-detection
50
+ logic (`Coatepec::Spec::FlakyChecker`, framework-agnostic in principle
51
+ since it only depends on `Runner#run`'s result shape) could target
52
+ Minitest too.
53
+ - Whether Minitest's own `--seed` (it has one; Minitest randomizes test
54
+ order by default too) is enough of an equivalent to RSpec's `--seed`
55
+ for `rails_spec_flaky_check` to reuse the same "rerun N times with a
56
+ fresh random seed" mechanism unchanged.
57
+
58
+ ## Get stats on a spec/test run (TestProf / FactoryProf) — designed, implemented, paused
59
+
60
+ The general idea: a tool that runs a spec selection and comes back with
61
+ more than pass/fail — profiling data about the run itself (factory
62
+ creation counts/timing being the concrete first target, via
63
+ [TestProf](https://test-prof.evilmartians.io/)'s FactoryProf profiler,
64
+ which is the one TestProf profiler with real structured JSON output;
65
+ others like EventProf are text-only and out of scope for now).
66
+
67
+ A `rails_spec_profile` tool (runs a spec with TestProf's FactoryProf
68
+ enabled, returning factory usage stats alongside normal pass/fail data)
69
+ was fully designed and implemented, but paused before merging: TestProf's
70
+ `FPROF` env var only activates at Ruby's `require` time, not at RSpec-run
71
+ time as originally assumed, which makes it silently a no-op on
72
+ `ForkStrategy` (Linux's default) — confirmed empirically, not just
73
+ inferred. See `docs/superpowers/specs/2026-08-13-factory-prof-tool-design.md`
74
+ (local-only, not committed — see that repo's `.gitignore`) for the full
75
+ root-cause writeup and the fix options considered (forcing `SpawnStrategy`
76
+ for profiled runs specifically, vs. detecting and raising a clear error on
77
+ non-spawn strategies, vs. a Linux-only opt-out config knob). Paused
78
+ specifically to wait for real signal on how coatepec is actually used
79
+ (Linux vs. macOS, `macos_fork` adoption) before picking a fix, rather than
80
+ guessing.
81
+
82
+ ## Run Rails 8.1's built-in CI (`bin/ci`)
83
+
84
+ Rails 8.1 introduced `ActiveSupport::ContinuousIntegration`
85
+ (`activesupport/lib/active_support/continuous_integration.rb`) as the
86
+ engine behind a generated `bin/ci`/`config/ci.rb`: a small DSL (`step`,
87
+ `group`) that a new app's `config/ci.rb` uses to declare, by default,
88
+ `bin/setup`, `bin/rubocop`, `bin/bundler-audit`, `bin/importmap audit`
89
+ (if using importmap), `bin/brakeman --quiet --no-pager --exit-on-warn
90
+ --exit-on-error`, and the test suite -- the same steps the generated
91
+ GitHub Actions workflow runs, since that workflow just calls `bin/ci`.
92
+ A coatepec tool that ran this and reported back which steps
93
+ passed/failed would cover security audits (Brakeman, bundler-audit,
94
+ importmap audit) and style checks in one call, for any app that has
95
+ adopted this (Rails 8.1+ only -- confirmed against the actual generator
96
+ template and `ContinuousIntegration` source, not a blog summary).
97
+
98
+ **The real design obstacle, found while researching this (not yet
99
+ solved):** `ContinuousIntegration` has no structured output at all. Each
100
+ `step` runs via plain `system(*command)` and writes colorized terminal
101
+ text (`✅ Title passed in 1.2s` / `❌ Title failed in 1.2s`); the only
102
+ machine-readable signal is the overall process exit code
103
+ (`abort unless success?`). Two options for whenever this gets designed:
104
+ scrape that text (fragile -- it's an internal, unversioned Rails string
105
+ format, not a documented API), or require the target app's `config/ci.rb`
106
+ in-process and read `ContinuousIntegration#results` (an array of
107
+ `[success, title]` pairs) directly, intercepting the `abort` that
108
+ normally follows a failure -- more work, but reads real data instead of
109
+ parsing text designed for a terminal.
110
+
111
+ Also worth deciding: whether this becomes its own tool (`rails_ci_run`?)
112
+ or folds into an existing one, and whether "security audits" specifically
113
+ (Brakeman/bundler-audit/importmap audit, independent of the rest of
114
+ `bin/ci`) are worth exposing as a narrower, separate tool for apps that
115
+ don't use Rails 8.1's `bin/ci` scaffold at all but do have those gems.
116
+
117
+ ## Controller introspection
118
+
119
+ A `rails_controller`-style tool mirroring the existing `rails_model`
120
+ (`Coatepec::Introspection::Model`) and `rails_routes`
121
+ (`Coatepec::Introspection::Routes`) tools' shape: given a controller
122
+ constant, return its actions, `before_action`/`around_action`/`after_action`
123
+ filters (and which actions they apply to), strong-parameter method
124
+ definitions, and included concerns -- via real Rails introspection APIs
125
+ (`ActionController::Base` callback chains, not source parsing), the same
126
+ trust boundary `rails_model` already holds to.
127
+
128
+ ## Background job introspection -- built, then parked (PR #14, closed unmerged)
129
+
130
+ The original idea: a `rails_job`-style tool for `ActiveJob` classes --
131
+ queue name, retry/discard configuration, and callbacks -- following the
132
+ same bounded, read-only, real-API-not-source-parsing pattern as
133
+ `rails_model`, since coatepec has no visibility into background jobs at
134
+ all today.
135
+
136
+ It was fully built and reviewed as `rails_job`
137
+ (`Coatepec::Introspection::Job`), then **closed unmerged** in
138
+ [PR #14](https://github.com/mogox/coatepec/pull/14) -- the diff and its
139
+ review history stay there, so nothing needs re-deriving if this is picked
140
+ back up.
141
+
142
+ **Why it was parked:** the tool only sees `ActiveJob::Base` subclasses.
143
+ That's not an implementation shortcut -- it's the trust boundary doing its
144
+ job: the whole design reads real Rails introspection APIs
145
+ (`_perform_callbacks`, `rescue_handlers`, the `queue_name`/`priority` class
146
+ attributes) rather than parsing source, and those APIs exist only on
147
+ ActiveJob. An app whose jobs are native Sidekiq (`include Sidekiq::Job`)
148
+ or Delayed::Job classes gets `:not_active_job` and nothing else, because
149
+ those classes genuinely aren't ActiveJob jobs. (Sidekiq used *as the
150
+ ActiveJob queue adapter* is fine -- those jobs still subclass
151
+ `ApplicationJob`; it's hand-written Sidekiq/delayed_job worker classes
152
+ that fall outside.) Covering them would mean a second, adapter-specific
153
+ introspection path per backend -- `sidekiq_options` for retry/queue/dead,
154
+ `Delayed::Worker` config and `handle_asynchronously` for delayed_job --
155
+ which is a materially bigger design than the ActiveJob one, not a small
156
+ extension of it. Given a limited number of iterations to spend, that
157
+ budget goes to features usable across more real apps first.
158
+
159
+ Two constraints worth keeping if this is revisited:
160
+ - Even within ActiveJob, `retry_on` / `discard_on` / plain `rescue_from`
161
+ are indistinguishable, and neither macro's `wait:`/`attempts:`/`queue:`/
162
+ `priority:` options are introspectable -- ActiveJob closes over them
163
+ inside a Proc rather than storing them as class metadata. Only the
164
+ *list* of rescued exception classes is genuinely queryable.
165
+ - `queue_as { ... }` and `queue_with_priority { ... }` store unevaluated
166
+ app blocks. Reading them naively either executes app code at
167
+ introspection time or leaks the app's absolute source paths through
168
+ `Proc#to_s`. PR #14 has the fix for both; any future version needs the
169
+ same care.
170
+
171
+ ## Cross-file consistency validation
172
+
173
+ Distinct from anything coatepec does today: a tool that checks for drift
174
+ *across* files rather than introspecting one thing at a time -- a route
175
+ pointing at a controller action that doesn't exist, a `belongs_to`/`has_many`
176
+ referencing a column or table that isn't in the schema, that kind of thing.
177
+ Surfaced while researching prior art (a competing tool does this via
178
+ source-code parsing); would need its own design for how to do it via
179
+ structured Rails APIs instead, consistent with how every other coatepec
180
+ tool avoids parsing source directly.
181
+
182
+ ## Considered and set aside
183
+
184
+ - **Environment variable / credentials discovery** (a prior-art tool
185
+ exposes this). Cuts directly against coatepec's "no credential access"
186
+ security boundary -- not a fit regardless of usefulness.
187
+ - **AST-based code pattern analysis** (concerns, callbacks, service
188
+ objects, helper methods, by parsing source rather than calling real
189
+ Rails APIs). A meaningfully different, heavier, more fragile approach
190
+ than every existing coatepec tool takes. Not ruled out forever, but a
191
+ clear departure from the project's current trust model, not a natural
192
+ extension of it.
193
+ - **Rails dev server lifecycle management** (start/stop/monitor `rails s`
194
+ via MCP -- a different tool in the ecosystem does exactly this). A
195
+ different category of feature (process lifecycle, not test/introspection)
196
+ from everything else on this list; noted here for completeness, not
197
+ actively being considered.
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Introspection
5
+ # Returns bounded ActionController class metadata for a single controller,
6
+ # for the rails_controller MCP tool. Pure reflection over an already-loaded,
7
+ # already-gated class -- no request dispatch, no action execution, and no
8
+ # evaluation of app-authored callback conditions.
9
+ # rubocop:disable Metrics/ClassLength -- Task 3 (route cross-referencing)
10
+ # adds genuinely cohesive functionality: routes_by_action, grouped_routes,
11
+ # route_data, and rails_routes exist solely to serve this class's single
12
+ # responsibility (reflect on one controller). Splitting them into a
13
+ # separate collaborator class would fragment that one responsibility
14
+ # across files for no readability gain, only to satisfy a line count.
15
+ class Controller
16
+ NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
17
+ MAX_ITEMS = 200
18
+
19
+ # AbstractController::Callbacks::ActionFilter#match? reads exactly two
20
+ # things off the controller it is handed: `action_name`, and (Rails 7.1+)
21
+ # `raise_on_missing_callback_actions`. That second one must be false --
22
+ # when true, match? raises ActionNotFound for any action named in `only:`
23
+ # that the controller doesn't define, which is precisely the drift this
24
+ # tool exists to *report*, so it must never raise here. This Struct is
25
+ # the entire controller surface match? touches; nothing on it can
26
+ # execute application code.
27
+ CallbackProbe = Struct.new(:action_name, :raise_on_missing_callback_actions)
28
+
29
+ def initialize(name)
30
+ @name = name
31
+ end
32
+
33
+ def call
34
+ validate_name!
35
+ klass = resolve!
36
+ validate_action_controller!(klass)
37
+ build_metadata(klass)
38
+ end
39
+
40
+ private
41
+
42
+ def build_metadata(klass)
43
+ actions = action_names(klass)
44
+ routes = routes_by_action(klass)
45
+ {
46
+ name: klass.name, controller_path: klass.controller_path,
47
+ actions: actions.map { |action| { name: action, routes: routes.fetch(action, []) } },
48
+ unroutable_actions: actions.reject { |action| routes.key?(action) },
49
+ routes_without_action: routes_without_action(klass, routes),
50
+ callbacks: callbacks_for(klass, actions), concerns: concerns_for(klass)
51
+ }
52
+ end
53
+
54
+ def validate_name!
55
+ return if @name.is_a?(String) && NAME_PATTERN.match?(@name)
56
+
57
+ raise Coatepec::Error.new(:invalid_controller_name, "#{@name.inspect} is not a valid constant name")
58
+ end
59
+
60
+ def resolve!
61
+ ::ActiveSupport::Inflector.safe_constantize(@name) ||
62
+ raise(Coatepec::Error.new(:controller_not_found, "#{@name} could not be resolved"))
63
+ end
64
+
65
+ # Gate on Metal, not Base: an ActionController::API controller is not a
66
+ # Base descendant, so gating on Base would reject every API-only app.
67
+ def validate_action_controller!(klass)
68
+ unless defined?(::ActionController::Metal)
69
+ raise Coatepec::Error.new(:not_action_controller, "ActionController is not loaded in this app")
70
+ end
71
+ return if klass.is_a?(Class) && klass < ::ActionController::Metal
72
+
73
+ raise Coatepec::Error.new(:not_action_controller, "#{@name} is not an ActionController controller")
74
+ end
75
+
76
+ # action_methods is a Set of Strings. Sorted for stable output across
77
+ # runs. A public method contributed by a concern legitimately appears
78
+ # here -- Rails really would route to it, so surfacing it is the point,
79
+ # not a leak to filter out.
80
+ def action_names(klass)
81
+ klass.action_methods.to_a.map(&:to_s).sort.first(MAX_ITEMS)
82
+ end
83
+
84
+ # Slice the ancestor chain at the first ActionController::* class in it:
85
+ # ActionController::Base for a normal controller, ActionController::API
86
+ # for an API-only one. Everything before that point was inserted by the
87
+ # app, so this needs no denylist of the ~60 framework modules below it.
88
+ # Anonymous modules have a nil name and are dropped.
89
+ def concerns_for(klass)
90
+ base = framework_base(klass)
91
+ klass.ancestors
92
+ .take_while { |mod| mod != base }
93
+ .reject { |mod| mod.is_a?(Class) }
94
+ .filter_map(&:name)
95
+ .first(MAX_ITEMS)
96
+ end
97
+
98
+ def framework_base(klass)
99
+ klass.ancestors.find do |mod|
100
+ mod.is_a?(Class) && mod.name.to_s.start_with?("ActionController::")
101
+ end
102
+ end
103
+
104
+ # controller_path is the public, correctly-namespaced key Rails itself
105
+ # stores in a route's defaults (Admin::ReportsController =>
106
+ # "admin/reports"), so matching on it needs no name munging.
107
+ #
108
+ # Only Rails.application.routes is read, so a controller mounted inside
109
+ # an engine will report its actions as unroutable even though the
110
+ # engine's own route set reaches them. Introspection::Routes has exactly
111
+ # the same boundary today; it is documented in the README rather than
112
+ # silently absorbed.
113
+ # A route whose defaults[:action] is nil or empty (a mount or redirect)
114
+ # is skipped, not recorded under an empty-string action.
115
+ def routes_by_action(klass)
116
+ grouped_routes(klass).transform_values { |list| list.first(MAX_ITEMS).map { |route| route_data(route) } }
117
+ end
118
+
119
+ def grouped_routes(klass)
120
+ path = klass.controller_path
121
+ matching = self.class.rails_routes.select { |route| route.defaults[:controller].to_s == path }
122
+ matching.group_by { |route| route.defaults[:action].to_s }.reject { |action, _| action.empty? }
123
+ end
124
+
125
+ # Differenced against the controller's full action_methods set, not the
126
+ # (possibly truncated-to-MAX_ITEMS) displayed `actions` list -- a
127
+ # controller with more than MAX_ITEMS action methods would otherwise
128
+ # have every route whose action fell past the truncation point reported
129
+ # here as a false positive, in the field the README calls the tool's
130
+ # most actionable output. Bounded to MAX_ITEMS like every other
131
+ # collection in this payload; `grouped_routes` itself caps each route
132
+ # *list* but not its key count, so this is where that cap belongs.
133
+ def routes_without_action(klass, routes)
134
+ defined_actions = klass.action_methods.map(&:to_s)
135
+ (routes.keys - defined_actions).sort.first(MAX_ITEMS)
136
+ end
137
+
138
+ # path keeps Rails' raw spec, `(.:format)` suffix included, so a path
139
+ # string here is byte-identical to the same route as reported by
140
+ # rails_routes. Stripping it would make the two tools disagree about the
141
+ # same route.
142
+ def route_data(route)
143
+ { verb: route.verb.to_s, path: route.path.spec.to_s, route_name: route.name&.to_s }
144
+ end
145
+
146
+ # Isolated as a class method purely so unit tests can stub it without
147
+ # booting Rails -- Rails.application.routes.routes is otherwise only
148
+ # reachable with a real, booted application. Mirrors
149
+ # Introspection::Routes.rails_routes.
150
+ # rubocop:disable Lint/IneffectiveAccessModifier
151
+ def self.rails_routes
152
+ Rails.application.routes.routes
153
+ end
154
+ # rubocop:enable Lint/IneffectiveAccessModifier
155
+
156
+ # Gated on Metal, not Base/API, so a bare ActionController::Metal
157
+ # subclass -- which passes validate_action_controller!'s class gate but
158
+ # does not include AbstractController::Callbacks, unlike Base and API --
159
+ # gets an empty callback list instead of a NoMethodError.
160
+ def callbacks_for(klass, actions)
161
+ return [] unless klass.respond_to?(:_process_action_callbacks)
162
+
163
+ klass._process_action_callbacks.first(MAX_ITEMS).map do |callback|
164
+ conditions_for(callback, actions)
165
+ .merge(kind: callback.kind.to_s, filter: filter_description(callback.filter))
166
+ end
167
+ end
168
+
169
+ # only:/except: do not survive as readable options. Rails compiles both
170
+ # into an ActionFilter and distinguishes them purely by *placement*: the
171
+ # `only:` filter lands in the callback's @if chain, the `except:` one in
172
+ # its @unless chain. Intent is therefore recovered from which chain the
173
+ # object sits in, not from the object itself.
174
+ #
175
+ # Reaching those chains needs instance_variable_get: Callback exposes
176
+ # `kind` and `filter` publicly but has no reader for @if/@unless. That
177
+ # single private read is unavoidable; having taken it, the action set is
178
+ # then read through ActionFilter's *public* match? rather than a second
179
+ # private read of its @actions, so this keeps working if Rails changes
180
+ # how ActionFilter stores them.
181
+ def conditions_for(callback, actions)
182
+ ifs = Array(callback.instance_variable_get(:@if))
183
+ unlesses = Array(callback.instance_variable_get(:@unless))
184
+ {
185
+ only: matched_actions(ifs, actions, :all?),
186
+ except: matched_actions(unlesses, actions, :any?),
187
+ if: plain_conditions(ifs),
188
+ unless: plain_conditions(unlesses)
189
+ }
190
+ end
191
+
192
+ # nil (not []) when there is no ActionFilter at all: "this callback is
193
+ # unrestricted" and "this callback is restricted to no actions" are
194
+ # different facts and must not serialize identically.
195
+ #
196
+ # A chain routinely carries *more than one* ActionFilter: skip_callback
197
+ # (ActiveSupport::Callbacks::Callback#merge_conditional_options)
198
+ # concatenates a skip's normalized only:/except: onto the callback's
199
+ # existing @if/@unless chain rather than replacing it, so any
200
+ # `skip_before_action ..., only:`/`except:` leaves two ActionFilters
201
+ # behind. A callback only runs when *every* @if condition holds and
202
+ # *none* of its @unless conditions hold (ActiveSupport::Callbacks'
203
+ # run_callbacks ANDs @if and ANDs the negation of each @unless), so:
204
+ # only: is every @if ActionFilter's matches intersected (combinator
205
+ # :all? -- all must hold for the action to run the callback), and
206
+ # except: is every @unless ActionFilter's matches unioned (combinator
207
+ # :any? -- any one holding is enough to skip it). Keeping only the
208
+ # first ActionFilter in the chain (as a naive `find` would) silently
209
+ # drops every skip layered on top of it, which always biases toward
210
+ # over-reporting protection -- exactly backwards for a tool whose job is
211
+ # to answer "is this action protected?".
212
+ def matched_actions(conditions, actions, combinator)
213
+ filters = conditions.select { |condition| action_filter?(condition) }
214
+ return nil if filters.empty?
215
+
216
+ actions.select do |action|
217
+ filters.public_send(combinator) { |filter| filter.match?(CallbackProbe.new(action, false)) }
218
+ end
219
+ end
220
+
221
+ def action_filter?(condition)
222
+ defined?(::AbstractController::Callbacks::ActionFilter) &&
223
+ condition.is_a?(::AbstractController::Callbacks::ActionFilter)
224
+ end
225
+
226
+ # The conditions that are *not* only:/except: -- a real `if:`/`unless:`.
227
+ # A Symbol is a method reference and safe to name; a Proc must never be
228
+ # serialized (Proc#to_s leaks the app's absolute source path).
229
+ def plain_conditions(conditions)
230
+ conditions.reject { |condition| action_filter?(condition) }
231
+ .map { |condition| filter_description(condition) }
232
+ end
233
+
234
+ # A Symbol filter (the method-reference form, e.g.
235
+ # `before_action :require_login`) is a method reference and safe to
236
+ # report by name; a Proc must never be serialized, because Proc#to_s
237
+ # leaks the app's absolute source path, so it is reduced to "(block)"
238
+ # instead. Introspection::SafeOptions guards the same class of leak
239
+ # elsewhere, by silently dropping Procs from an options hash rather than
240
+ # substituting a placeholder -- a different mechanism for the same rule.
241
+ def filter_description(filter)
242
+ case filter
243
+ when Symbol then filter.to_s
244
+ when Proc then "(block)"
245
+ else filter.class.name || "(anonymous filter class)"
246
+ end
247
+ end
248
+ end
249
+ # rubocop:enable Metrics/ClassLength
250
+ end
251
+ end
@@ -201,5 +201,38 @@ module Coatepec
201
201
  end
202
202
  end
203
203
  end
204
+
205
+ # The `rails_controller` MCP tool: returns a controller's actions, action
206
+ # callbacks, included concerns, and the routes reaching each action.
207
+ class ControllerTool < ::MCP::Tool
208
+ tool_name "rails_controller"
209
+ description "Return a Rails controller's actions, action callbacks, concerns, and the routes " \
210
+ "reaching each action, including unroutable actions and routes with no matching action"
211
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
212
+ input_schema(
213
+ properties: {
214
+ name: { type: "string", pattern: '^[A-Z]\w*(?:::[A-Z]\w*)*$' }
215
+ },
216
+ required: ["name"],
217
+ additionalProperties: false
218
+ )
219
+
220
+ class << self
221
+ def call(name:, server_context:)
222
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
+ data = server_context[:worker_manager].controller(name: name)
224
+ Response.ok(data: data, meta: meta_for(server_context, started_at))
225
+ rescue Coatepec::Error => e
226
+ Response.error(e)
227
+ end
228
+
229
+ private
230
+
231
+ def meta_for(server_context, started_at)
232
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
233
+ { project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
234
+ end
235
+ end
236
+ end
204
237
  end
205
238
  end
data/lib/coatepec/mcp.rb CHANGED
@@ -13,14 +13,15 @@ require_relative "mcp/tools"
13
13
 
14
14
  module Coatepec
15
15
  # Wires the `rails_spec_run`, `rails_runtime_status`, `rails_runtime_restart`,
16
- # `rails_spec_flaky_check`, `rails_routes`, and `rails_model` tools into an
17
- # `::MCP::Server` instance backed by the given project's worker manager.
16
+ # `rails_spec_flaky_check`, `rails_routes`, `rails_model`, and `rails_controller`
17
+ # tools into an `::MCP::Server` instance backed by the given project's worker manager.
18
18
  module MCP
19
19
  def self.build_server(project:, worker_manager:)
20
20
  ::MCP::Server.new(
21
21
  name: "coatepec",
22
22
  version: Coatepec::VERSION,
23
- tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, FlakyCheckTool, RoutesTool, ModelTool],
23
+ tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, FlakyCheckTool, RoutesTool, ModelTool,
24
+ ControllerTool],
24
25
  server_context: { worker_manager: worker_manager, project_root: project.root }
25
26
  )
26
27
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coatepec
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -45,6 +45,7 @@ module Coatepec
45
45
  when "flaky_check" then handle_flaky_check(args)
46
46
  when "routes" then handle_routes(args)
47
47
  when "model" then handle_model(args)
48
+ when "controller" then handle_controller(args)
48
49
  else
49
50
  raise Coatepec::Error.new(:internal_error, "Unknown command #{command}")
50
51
  end
@@ -65,6 +66,10 @@ module Coatepec
65
66
  def handle_model(args)
66
67
  Introspection::Model.new(args.transform_keys(&:to_sym)[:name]).call
67
68
  end
69
+
70
+ def handle_controller(args)
71
+ Introspection::Controller.new(args.transform_keys(&:to_sym)[:name]).call
72
+ end
68
73
  end
69
74
  end
70
75
  end
@@ -48,6 +48,10 @@ module Coatepec
48
48
  dispatch("model", { name: name }, timeout: 30)
49
49
  end
50
50
 
51
+ def controller(name:)
52
+ dispatch("controller", { name: name }, timeout: 30)
53
+ end
54
+
51
55
  def stop
52
56
  @lock.synchronize { @client&.stop }
53
57
  end
data/lib/coatepec.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "coatepec/worker/rails_runtime"
12
12
  require_relative "coatepec/introspection/routes"
13
13
  require_relative "coatepec/introspection/safe_options"
14
14
  require_relative "coatepec/introspection/model"
15
+ require_relative "coatepec/introspection/controller"
15
16
  require_relative "coatepec/spec/result"
16
17
  require_relative "coatepec/spec/process_strategy"
17
18
  require_relative "coatepec/spec/fork_strategy"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coatepec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Enrique Mogollan
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-20 00:00:00.000000000 Z
10
+ date: 2026-09-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -59,12 +59,14 @@ files:
59
59
  - CHANGELOG.md
60
60
  - LICENSE.txt
61
61
  - README.md
62
+ - ROADMAP.md
62
63
  - Rakefile
63
64
  - exe/coatepec
64
65
  - exe/coatepec-worker
65
66
  - lib/coatepec.rb
66
67
  - lib/coatepec/cli.rb
67
68
  - lib/coatepec/errors.rb
69
+ - lib/coatepec/introspection/controller.rb
68
70
  - lib/coatepec/introspection/model.rb
69
71
  - lib/coatepec/introspection/routes.rb
70
72
  - lib/coatepec/introspection/safe_options.rb