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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be24375e5f36c63e0511dc268bacecd57a54a33a94f5d44540925927c49f6655
4
- data.tar.gz: 0e80f68351e4e814e9e4486f7e02d8d3f70512edaa7e9806c6baa3091111e779
3
+ metadata.gz: 9e0e730ee65e046252297e502039ca8db8b17b4d1774564f79cb708dbe4500b2
4
+ data.tar.gz: 66d1afbd32ef8ba95de436e046d5b8b90452578a5a1ee0af2a442f7a14f0054d
5
5
  SHA512:
6
- metadata.gz: 297b7eb82d12ece3991893ed1052650648d37fb86b438356257e67e90f69260297b5c3f4b390a2ce25860e2e06a8487dc1597bb39219c3f13ac550901cc92e94
7
- data.tar.gz: dbe1f843c872f42c20d7df9ee4226af83200b9c9457bb2a9b3aba185d22b7c5c8878fa3237a57dc4dfb4526c499e4c75cb1cb5c3135262aa24dd71fc4ea8805b
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 APIs
212
- (`Rails.application.routes.routes`, `ActiveRecord` reflection) -- never
213
- `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
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
@@ -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`, and `rails_model` tools into an `::MCP::Server` instance
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coatepec
4
- VERSION = "0.5.2"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -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.5.2
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-10 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
@@ -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