coatepec 0.5.2 → 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 +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +77 -3
- data/ROADMAP.md +197 -0
- data/lib/coatepec/introspection/controller.rb +251 -0
- data/lib/coatepec/mcp/tools.rb +74 -0
- data/lib/coatepec/mcp.rb +4 -3
- data/lib/coatepec/spec/flaky_checker.rb +87 -0
- data/lib/coatepec/version.rb +1 -1
- data/lib/coatepec/worker/server.rb +10 -0
- data/lib/coatepec/worker_manager.rb +16 -0
- data/lib/coatepec.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e0e730ee65e046252297e502039ca8db8b17b4d1774564f79cb708dbe4500b2
|
|
4
|
+
data.tar.gz: 66d1afbd32ef8ba95de436e046d5b8b90452578a5a1ee0af2a442f7a14f0054d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5168b16140e5199c41a11c4e9a73efee643d2acf80405090a7b1ff0ba32ab94432b1e02b396ca5456ba879960025ef640434fcc265ca19147633e00d0b47a88
|
|
7
|
+
data.tar.gz: 209b126ecc16dadfacad123098e06bcb9d53649f7f3a8b2eca7555a6d098d35f7307cc81b38b015ea0891b49ef088bbe55c3f33ea2de243e14e0212322e68ded
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
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
|
+
|
|
34
|
+
## 0.6.0
|
|
35
|
+
|
|
36
|
+
- Add `rails_spec_flaky_check`: runs a spec selection multiple times with
|
|
37
|
+
independently random seeds (RSpec's `--seed` is equivalent to `--order
|
|
38
|
+
rand:SEED`) and reports which examples' pass/fail status was
|
|
39
|
+
inconsistent across runs -- separately from examples that failed every
|
|
40
|
+
run (`consistently_failing`, not flaky) and examples that passed every
|
|
41
|
+
run (omitted). Each round's seed is included in the response so a
|
|
42
|
+
specific divergence can be reproduced with a plain `rails_spec_run`
|
|
43
|
+
call. `rails_spec_run` itself is unchanged by this release.
|
|
44
|
+
|
|
3
45
|
## 0.5.2
|
|
4
46
|
|
|
5
47
|
- Fix `WorkerManager` getting permanently stuck treating a dead worker as
|
data/README.md
CHANGED
|
@@ -130,6 +130,8 @@ 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 |
|
|
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 |
|
|
133
135
|
|
|
134
136
|
### Example queries
|
|
135
137
|
|
|
@@ -156,6 +158,77 @@ a no-op.
|
|
|
156
158
|
rather than introspecting it (a nonexistent constant raises `model_not_found`
|
|
157
159
|
instead) -- the tool only ever reflects on `ActiveRecord::Base` descendants.
|
|
158
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
|
+
|
|
208
|
+
`rails_spec_flaky_check`:
|
|
209
|
+
|
|
210
|
+
- "Is this spec flaky?" -- `rails_spec_flaky_check(paths: ["spec/models/widget_spec.rb"])`
|
|
211
|
+
runs it 5 times (default), each with an independently random seed
|
|
212
|
+
(equivalent to RSpec's `--order rand:SEED`), and reports any example
|
|
213
|
+
whose pass/fail status wasn't the same every time under `flaky_examples`
|
|
214
|
+
-- distinct from `consistently_failing` (fails every run: broken, not
|
|
215
|
+
flaky) and examples that passed every run (omitted -- nothing to report).
|
|
216
|
+
- Each round's seed is included in the response (`rounds[].seed`), so a
|
|
217
|
+
specific divergence can be reproduced afterward with a plain
|
|
218
|
+
`rails_spec_run(seed: <that seed>)`.
|
|
219
|
+
- `timeout_seconds` is a **per-round** budget, not a total; `runs *
|
|
220
|
+
timeout_seconds` is capped at 1800s combined (`flaky_check_budget_exceeded`
|
|
221
|
+
if exceeded) since this tool can run for a while.
|
|
222
|
+
- Selections over 500 examples are capped per round (the same limit
|
|
223
|
+
`rails_spec_run` already has), and each round samples a different subset,
|
|
224
|
+
since execution order varies by design -- for suites this large, narrow
|
|
225
|
+
`paths`/`example` rather than passing a very broad directory selection.
|
|
226
|
+
- `statuses[]` only aligns positionally with `rounds[]` when no round
|
|
227
|
+
crashed -- a round whose process itself failed contributes no entry to
|
|
228
|
+
`statuses[]` (though it still appears in `rounds[]`), so treat positional
|
|
229
|
+
correspondence as best-effort, not guaranteed, when a round's `status` in
|
|
230
|
+
`rounds[]` looks like an outright crash rather than a normal pass/fail.
|
|
231
|
+
|
|
159
232
|
The warm test worker forces Rails' reload-checking on for its own boot,
|
|
160
233
|
regardless of the target app's own `test.rb` setting (which disables it by
|
|
161
234
|
default) -- so editing a model file takes effect on the next tool call
|
|
@@ -208,9 +281,10 @@ principle.
|
|
|
208
281
|
Coatepec takes the opposite approach: there's no eval, console, or SQL
|
|
209
282
|
tool to begin with. `rails_spec_run` only ever executes RSpec files that
|
|
210
283
|
already exist under the app's own allowed spec roots, and `rails_routes`/
|
|
211
|
-
`rails_model` only ever call structured, read-only Rails
|
|
212
|
-
(`Rails.application.routes.routes`, `ActiveRecord` reflection
|
|
213
|
-
`
|
|
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
|
|
214
288
|
you genuinely need a Rails console over MCP, Rails Active MCP is built for
|
|
215
289
|
that; Coatepec is for teams who want an agent to run specs and read
|
|
216
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
|
data/lib/coatepec/mcp/tools.rb
CHANGED
|
@@ -94,6 +94,47 @@ module Coatepec
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# The `rails_spec_flaky_check` MCP tool: runs targeted RSpec examples
|
|
98
|
+
# multiple times with independently random seeds and reports which
|
|
99
|
+
# examples' pass/fail status was inconsistent across rounds. Separate
|
|
100
|
+
# tool from rails_spec_run for the same reason rails_spec_profile is --
|
|
101
|
+
# see docs/superpowers/specs/2026-08-13-flaky-spec-detection-design.md.
|
|
102
|
+
class FlakyCheckTool < ::MCP::Tool
|
|
103
|
+
tool_name "rails_spec_flaky_check"
|
|
104
|
+
description "Run targeted RSpec examples multiple times with random seeds to detect order-dependent or " \
|
|
105
|
+
"intermittent flakiness, reporting which examples' status was inconsistent across runs"
|
|
106
|
+
annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
|
|
107
|
+
input_schema(
|
|
108
|
+
properties: {
|
|
109
|
+
paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 },
|
|
110
|
+
example: { type: %w[string null] },
|
|
111
|
+
timeout_seconds: { type: "integer", minimum: 1, maximum: 900 },
|
|
112
|
+
runs: { type: "integer", minimum: 2, maximum: 20 }
|
|
113
|
+
},
|
|
114
|
+
required: ["paths"],
|
|
115
|
+
additionalProperties: false
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
def call(paths:, server_context:, example: nil, timeout_seconds: 120, runs: 5)
|
|
120
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
121
|
+
data = server_context[:worker_manager].check_flaky(
|
|
122
|
+
paths: paths, example: example, timeout_seconds: timeout_seconds, runs: runs
|
|
123
|
+
)
|
|
124
|
+
Response.ok(data: data, meta: meta_for(server_context, started_at))
|
|
125
|
+
rescue Coatepec::Error => e
|
|
126
|
+
Response.error(e)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def meta_for(server_context, started_at)
|
|
132
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
133
|
+
{ project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
97
138
|
# The `rails_routes` MCP tool: lists/filters/paginates the target
|
|
98
139
|
# Rails app's routes.
|
|
99
140
|
class RoutesTool < ::MCP::Tool
|
|
@@ -160,5 +201,38 @@ module Coatepec
|
|
|
160
201
|
end
|
|
161
202
|
end
|
|
162
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
|
|
163
237
|
end
|
|
164
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_routes`,
|
|
17
|
-
# 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, 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
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module Spec
|
|
7
|
+
# Runs a spec selection multiple times, each round with an
|
|
8
|
+
# independently random seed (RSpec's --seed N is equivalent to --order
|
|
9
|
+
# rand:N, confirmed against rspec-core's own option_parser.rb), and
|
|
10
|
+
# groups per-example pass/fail status across rounds by the example's
|
|
11
|
+
# own id -- stable regardless of execution order, since it's derived
|
|
12
|
+
# from declaration position/nesting, not from --order. Composes Runner
|
|
13
|
+
# unchanged, exactly like FactoryProfRunner: every round is a plain,
|
|
14
|
+
# ordinary Runner#run call with a different seed, no strategy override
|
|
15
|
+
# needed (unlike FactoryProfRunner, this feature has no load-time
|
|
16
|
+
# activation trick to fight -- see
|
|
17
|
+
# docs/superpowers/specs/2026-08-13-flaky-spec-detection-design.md).
|
|
18
|
+
class FlakyChecker
|
|
19
|
+
DEFAULT_RUNS = 5
|
|
20
|
+
MAX_TOTAL_SECONDS = 1800 # timeout_seconds * runs must not exceed this
|
|
21
|
+
MAX_ITEMS = 200 # same bound rails_model/FactoryProfRunner use elsewhere
|
|
22
|
+
|
|
23
|
+
def initialize(project_root, rails_runtime: nil)
|
|
24
|
+
@runner = Runner.new(project_root, rails_runtime: rails_runtime)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(paths:, example: nil, timeout_seconds: Runner::DEFAULT_TIMEOUT, runs: DEFAULT_RUNS)
|
|
28
|
+
validate_budget!(timeout_seconds, runs)
|
|
29
|
+
rounds = Array.new(runs) { run_one_round(paths, example, timeout_seconds) }
|
|
30
|
+
build_report(rounds)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_budget!(timeout_seconds, runs)
|
|
36
|
+
return if timeout_seconds * runs <= MAX_TOTAL_SECONDS
|
|
37
|
+
|
|
38
|
+
raise Coatepec::Error.new(
|
|
39
|
+
:flaky_check_budget_exceeded,
|
|
40
|
+
"timeout_seconds (#{timeout_seconds}) * runs (#{runs}) exceeds the " \
|
|
41
|
+
"#{MAX_TOTAL_SECONDS}s combined budget -- lower one or both"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run_one_round(paths, example, timeout_seconds)
|
|
46
|
+
seed = SecureRandom.random_number(65_536)
|
|
47
|
+
result = @runner.run(
|
|
48
|
+
paths: paths, example: example, seed: seed, fail_fast: false,
|
|
49
|
+
timeout_seconds: timeout_seconds
|
|
50
|
+
)
|
|
51
|
+
{ seed: seed, status: result[:status], examples: result[:examples] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_report(rounds)
|
|
55
|
+
by_id = group_examples_by_id(rounds)
|
|
56
|
+
{
|
|
57
|
+
runs: rounds.size,
|
|
58
|
+
rounds: rounds.map { |r| { seed: r[:seed], status: r[:status] } },
|
|
59
|
+
flaky_examples: classify(by_id) { |statuses| statuses.uniq.size > 1 },
|
|
60
|
+
consistently_failing: classify(by_id) { |statuses| statuses.uniq == ["failed"] }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rounds whose RSpec process itself crashed/timed out (not a specific
|
|
65
|
+
# example failing -- the whole run killed before its JSON summary was
|
|
66
|
+
# ever written) contribute no per-example data here. Excluded rather
|
|
67
|
+
# than counted as "every example failed", which would misrepresent a
|
|
68
|
+
# dead process as evidence against examples that never actually ran;
|
|
69
|
+
# the round itself is still visible in the `rounds:` summary.
|
|
70
|
+
def group_examples_by_id(rounds)
|
|
71
|
+
rounds.each_with_object({}) do |round, acc|
|
|
72
|
+
round[:examples].each { |ex| (acc[ex[:id]] ||= { meta: ex, statuses: [] })[:statuses] << ex[:status] }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def classify(by_id)
|
|
77
|
+
by_id.values.select { |v| yield(v[:statuses]) }.first(MAX_ITEMS).map do |v|
|
|
78
|
+
v[:meta].slice(:id, :description, :file_path, :line_number).merge(
|
|
79
|
+
statuses: v[:statuses],
|
|
80
|
+
pass_count: v[:statuses].count("passed"),
|
|
81
|
+
failure_count: v[:statuses].count("failed")
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/coatepec/version.rb
CHANGED
|
@@ -42,8 +42,10 @@ module Coatepec
|
|
|
42
42
|
case command
|
|
43
43
|
when "status" then @runtime.status
|
|
44
44
|
when "spec_run" then handle_spec_run(args)
|
|
45
|
+
when "flaky_check" then handle_flaky_check(args)
|
|
45
46
|
when "routes" then handle_routes(args)
|
|
46
47
|
when "model" then handle_model(args)
|
|
48
|
+
when "controller" then handle_controller(args)
|
|
47
49
|
else
|
|
48
50
|
raise Coatepec::Error.new(:internal_error, "Unknown command #{command}")
|
|
49
51
|
end
|
|
@@ -53,6 +55,10 @@ module Coatepec
|
|
|
53
55
|
Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
|
|
54
56
|
end
|
|
55
57
|
|
|
58
|
+
def handle_flaky_check(args)
|
|
59
|
+
Spec::FlakyChecker.new(@project_root, rails_runtime: @runtime).call(**args.transform_keys(&:to_sym))
|
|
60
|
+
end
|
|
61
|
+
|
|
56
62
|
def handle_routes(args)
|
|
57
63
|
Introspection::Routes.new(**args.transform_keys(&:to_sym)).call
|
|
58
64
|
end
|
|
@@ -60,6 +66,10 @@ module Coatepec
|
|
|
60
66
|
def handle_model(args)
|
|
61
67
|
Introspection::Model.new(args.transform_keys(&:to_sym)[:name]).call
|
|
62
68
|
end
|
|
69
|
+
|
|
70
|
+
def handle_controller(args)
|
|
71
|
+
Introspection::Controller.new(args.transform_keys(&:to_sym)[:name]).call
|
|
72
|
+
end
|
|
63
73
|
end
|
|
64
74
|
end
|
|
65
75
|
end
|
|
@@ -28,6 +28,18 @@ module Coatepec
|
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
def check_flaky(paths:, example:, timeout_seconds:, runs:)
|
|
32
|
+
dispatch(
|
|
33
|
+
"flaky_check",
|
|
34
|
+
{ paths: paths, example: example, timeout_seconds: timeout_seconds, runs: runs },
|
|
35
|
+
# Each round costs timeout_seconds *plus* real per-round overhead (process
|
|
36
|
+
# termination/spawn/result-parsing -- roughly 0.6s+ per round from
|
|
37
|
+
# ProcessStrategy#terminate's own escalation sleeps alone), so the flat
|
|
38
|
+
# base slack below is scaled by an extra ~2s per round on top of it.
|
|
39
|
+
timeout: (timeout_seconds * runs) + (2 * runs) + 10
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
31
43
|
def routes(query: nil, limit: 50, offset: 0)
|
|
32
44
|
dispatch("routes", { query: query, limit: limit, offset: offset }, timeout: 30)
|
|
33
45
|
end
|
|
@@ -36,6 +48,10 @@ module Coatepec
|
|
|
36
48
|
dispatch("model", { name: name }, timeout: 30)
|
|
37
49
|
end
|
|
38
50
|
|
|
51
|
+
def controller(name:)
|
|
52
|
+
dispatch("controller", { name: name }, timeout: 30)
|
|
53
|
+
end
|
|
54
|
+
|
|
39
55
|
def stop
|
|
40
56
|
@lock.synchronize { @client&.stop }
|
|
41
57
|
end
|
data/lib/coatepec.rb
CHANGED
|
@@ -12,12 +12,14 @@ 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"
|
|
18
19
|
require_relative "coatepec/spec/spawn_strategy"
|
|
19
20
|
require_relative "coatepec/spec/guarded_fork_strategy"
|
|
20
21
|
require_relative "coatepec/spec/runner"
|
|
22
|
+
require_relative "coatepec/spec/flaky_checker"
|
|
21
23
|
require_relative "coatepec/worker/server"
|
|
22
24
|
require_relative "coatepec/worker_manager"
|
|
23
25
|
|
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.
|
|
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-
|
|
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
|
|
@@ -74,6 +76,7 @@ files:
|
|
|
74
76
|
- lib/coatepec/project.rb
|
|
75
77
|
- lib/coatepec/project_config.rb
|
|
76
78
|
- lib/coatepec/protocol.rb
|
|
79
|
+
- lib/coatepec/spec/flaky_checker.rb
|
|
77
80
|
- lib/coatepec/spec/fork_strategy.rb
|
|
78
81
|
- lib/coatepec/spec/guarded_fork_strategy.rb
|
|
79
82
|
- lib/coatepec/spec/path_policy.rb
|