coatepec 0.7.0 → 0.8.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/.rubocop.yml +5 -0
- data/CHANGELOG.md +111 -0
- data/README.md +107 -31
- data/ROADMAP.md +28 -62
- data/lib/coatepec/introspection/bounded_options.rb +22 -0
- data/lib/coatepec/introspection/controller.rb +19 -28
- data/lib/coatepec/introspection/model.rb +33 -43
- data/lib/coatepec/introspection/model_resolver.rb +32 -0
- data/lib/coatepec/introspection/route_entries.rb +77 -0
- data/lib/coatepec/introspection/routes.rb +70 -20
- data/lib/coatepec/mcp/response.rb +16 -9
- data/lib/coatepec/mcp/tools.rb +85 -90
- data/lib/coatepec/mcp.rb +2 -2
- data/lib/coatepec/project.rb +11 -1
- data/lib/coatepec/spec/failure_collapser.rb +111 -0
- data/lib/coatepec/spec/flaky_checker.rb +3 -1
- data/lib/coatepec/spec/fork_strategy.rb +3 -12
- data/lib/coatepec/spec/guarded_fork_strategy.rb +11 -10
- data/lib/coatepec/spec/path_policy.rb +45 -12
- data/lib/coatepec/spec/process_strategy.rb +22 -17
- data/lib/coatepec/spec/result.rb +61 -19
- data/lib/coatepec/spec/rspec_adapter.rb +58 -0
- data/lib/coatepec/spec/runner.rb +22 -24
- data/lib/coatepec/spec/spawn_strategy.rb +7 -8
- data/lib/coatepec/test_unit/adapter.rb +132 -0
- data/lib/coatepec/test_unit/child_entry.rb +33 -0
- data/lib/coatepec/test_unit/json_reporter.rb +107 -0
- data/lib/coatepec/test_unit/line_filtering.rb +46 -0
- data/lib/coatepec/version.rb +1 -1
- data/lib/coatepec/worker/server.rb +2 -1
- data/lib/coatepec/worker_manager.rb +8 -6
- data/lib/coatepec.rb +5 -0
- metadata +16 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 550a60896b3c267b12b5db85b787c52d93b97dd2b95409c8bf78943ea63a27d0
|
|
4
|
+
data.tar.gz: '09c6cbbc7c00dd604d2cb5dfbabbb0101aea3cadf5d24070f3254a144bb45012'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 484557dd0635d21f7669a12b6accab0684e4850e4ac91b6718b733bd23372aceaa5b903f387facb22fdc6b0c2d0a09dfb4805ace67e6b24c66f95c482240d69b
|
|
7
|
+
data.tar.gz: e3fcc83c1f752dfba27b1260584b2ee1d0df5707e36163d049015babe269290a2467b3018c0e3696d8d6c4d4fcaf5bf8b2e325a3792ac7d3bcb085cad433e73c
|
data/.rubocop.yml
CHANGED
|
@@ -15,6 +15,11 @@ Style/StringLiterals:
|
|
|
15
15
|
Style/StringLiteralsInInterpolation:
|
|
16
16
|
EnforcedStyle: double_quotes
|
|
17
17
|
|
|
18
|
+
Metrics/ParameterLists:
|
|
19
|
+
# Keyword-only signatures here mirror the MCP tools' input schemas, and a tool
|
|
20
|
+
# gaining an input shouldn't force a refactor; positional lists stay capped at 5.
|
|
21
|
+
CountKeywordArgs: false
|
|
22
|
+
|
|
18
23
|
Metrics/BlockLength:
|
|
19
24
|
# RSpec's describe/context/it DSL naturally produces long blocks; this is the
|
|
20
25
|
# standard exclusion used by rubocop-rspec's own default config. inherit_mode
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- Tool responses are now compact JSON rather than pretty-printed (22-32%
|
|
6
|
+
smaller on measured payloads); set `COATEPEC_PRETTY=1` on the server
|
|
7
|
+
process to restore indentation.
|
|
8
|
+
- `rails_spec_run` now omits passing examples from `examples` by default --
|
|
9
|
+
a 49-test green run drops from ~3,700 tokens to ~160 -- and `summary` gains
|
|
10
|
+
`pending_count`. Pass the new `include_passing: true` input to get the full
|
|
11
|
+
roster back (still capped at 500 examples). `rails_spec_flaky_check` is
|
|
12
|
+
unaffected: it still sees every example.
|
|
13
|
+
- `examples[].description` (and `flaky_examples[].description`) is omitted
|
|
14
|
+
when it would only repeat `id`, which is always the case for Minitest.
|
|
15
|
+
- `rails_routes` (with `engines: "include"`) and `rails_controller` now
|
|
16
|
+
include the routes of engines mounted in the application, expanded one
|
|
17
|
+
level deep (an engine mounted inside another engine stays an opaque mount
|
|
18
|
+
route, the same boundary `bin/rails routes` draws). Paths carry the mount
|
|
19
|
+
point, so `/widget_admin/audits` is what both tools report, and a
|
|
20
|
+
controller living inside an engine no longer has all of its actions listed
|
|
21
|
+
under `unroutable_actions`.
|
|
22
|
+
- `rails_routes` gains an `engine` column and `rails_controller`'s
|
|
23
|
+
`actions[].routes` entries an `engine` field: `null` for an application
|
|
24
|
+
route, the engine's class name otherwise. `rails_routes`' `query` matches
|
|
25
|
+
against it like every other column, so `query: "Avo::Engine"` with
|
|
26
|
+
`engines: "include"` or `"only"` returns exactly that engine's routes.
|
|
27
|
+
Application routes are listed before engine routes.
|
|
28
|
+
- An engine route's `name` (`route_name` in `rails_controller`) is relative
|
|
29
|
+
to its engine: it is reached through the mount's helper,
|
|
30
|
+
`<mount name>.<name>_path`, where the mount name is the `name` of the mount
|
|
31
|
+
route itself -- never as a top-level url helper.
|
|
32
|
+
- `rails_routes` and `rails_controller` now omit routes Rails marks
|
|
33
|
+
`internal` -- its own `/rails/info` and friends -- matching
|
|
34
|
+
`bin/rails routes`. This is a behaviour change for callers that relied on
|
|
35
|
+
seeing them.
|
|
36
|
+
- Add Minitest support to `rails_spec_run` and `rails_spec_flaky_check`:
|
|
37
|
+
selectors under a `test/` root ending in `_test.rb` run through Rails'
|
|
38
|
+
Minitest runner in the same warm worker, with the same
|
|
39
|
+
`example`/`seed`/`fail_fast`/`timeout_seconds` inputs and the same result
|
|
40
|
+
shape (`id` is `ClassName#test_method`; a skip is `"pending"`, an error is
|
|
41
|
+
`"failed"`). The framework is chosen per call from the selector paths --
|
|
42
|
+
an app that has both `spec/` and `test/` works without configuration; one
|
|
43
|
+
call may not mix the two (`mixed_test_frameworks`).
|
|
44
|
+
- The Minitest child sets `PARALLEL_WORKERS=1`, so a selection above Rails'
|
|
45
|
+
parallelization threshold runs serially under the call's single timeout
|
|
46
|
+
instead of forking a worker tree.
|
|
47
|
+
- `file:LINE` selection is implemented by Coatepec itself rather than relying
|
|
48
|
+
on Rails' line filtering, which is absent in apps generated with
|
|
49
|
+
`--skip-test` and targets a method Minitest 6 no longer calls on Rails 7.1.
|
|
50
|
+
- Path validation now runs before the framework is required, so an invalid
|
|
51
|
+
path on a Minitest-only app reports `invalid_spec_path` instead of
|
|
52
|
+
`unsupported_test_framework`. `invalid_spec_path` keeps its code for both
|
|
53
|
+
frameworks.
|
|
54
|
+
- `rails_model` collapses validators with identical name, attributes and
|
|
55
|
+
options into one entry (a concern and the model body declaring the same
|
|
56
|
+
validation used to appear twice); the 200-item cap now counts distinct
|
|
57
|
+
validators.
|
|
58
|
+
- `rails_routes` defaults to 100 routes per page (was 50 -- engine expansion
|
|
59
|
+
roughly doubled route counts when engines are included) and returns
|
|
60
|
+
`next_offset` for the follow-up call, `null` on the last page.
|
|
61
|
+
- `rails_spec_run`: when several tests fail with the same error text (a
|
|
62
|
+
broken layout erroring every controller test, say), `stdout` keeps the
|
|
63
|
+
first failure block and replaces each repeat with one roll-up line
|
|
64
|
+
naming the other tests -- a 5-error controller run drops from ~4,850 B to
|
|
65
|
+
roughly a third of that. Backtrace frames and RSpec's `Failure/Error:`
|
|
66
|
+
source line are ignored when deciding that two blocks match.
|
|
67
|
+
- `rails_spec_run` gains `include_stdout` (`failures`, the default; `always`;
|
|
68
|
+
`never`). A passing run's `stdout` is `null` by default -- its progress
|
|
69
|
+
dots and summary line only repeat `summary` -- and a failing run's is
|
|
70
|
+
returned; `always` and `never` override that either way. The key stays so
|
|
71
|
+
the result shape is uniform. `stderr` is always returned.
|
|
72
|
+
- `rails_routes` returns application routes only by default and gains
|
|
73
|
+
`engines` to change that: `include` lists the routes of mounted engines
|
|
74
|
+
too -- the routes 0.8.0 added by expanding engines -- and `only` lists just
|
|
75
|
+
those; the default matches pre-0.8.0 output (application routes, the mount
|
|
76
|
+
route included). Every response carries `engines` (the filter applied) and
|
|
77
|
+
`engines_excluded` (how many routes matching `query` were withheld), so the
|
|
78
|
+
omission is stated, never silent. The filter applies after `query` and
|
|
79
|
+
before paging, so `matched` and `next_offset` describe the kept set; an
|
|
80
|
+
engine's mount route counts as an application route. Measured on an app
|
|
81
|
+
with a mounted admin engine, six typical route queries cost 57% less
|
|
82
|
+
context with engine routes withheld.
|
|
83
|
+
- `rails_model` cuts an array-valued validator option longer than 20 entries
|
|
84
|
+
to its first 20 and adds `<option>_count` (the full length) and
|
|
85
|
+
`<option>_truncated: true` beside it -- a 249-code `inclusion` list no
|
|
86
|
+
longer costs 1.4 KB per model. Shorter lists are unchanged and carry no
|
|
87
|
+
sibling keys.
|
|
88
|
+
- Every tool response's `meta` is now just `duration_ms`; `project_root` is
|
|
89
|
+
reported once by `rails_runtime_status` (beside `environment`) instead of
|
|
90
|
+
on every call.
|
|
91
|
+
- `rails_spec_run` results carry `child_pid`, `signaled`, `termsig`,
|
|
92
|
+
`stopsig` and `coredump` only when the child did not exit normally (a
|
|
93
|
+
timeout kill or a crash); a normal run reports `status`, `exit_code` and
|
|
94
|
+
the output fields alongside the usual `summary` and `examples`.
|
|
95
|
+
- `rails_model` returns `name`, `table_name`, `primary_key`, `abstract_class`
|
|
96
|
+
and `counts` (the size of each of its four lists, after validator
|
|
97
|
+
de-duplication and the 200-item caps) by default, and gains `fields`, an
|
|
98
|
+
array of `columns`/`associations`/`validators`/`enums` naming the lists to
|
|
99
|
+
include; omitted or `[]` means counts only. Callers that read the lists
|
|
100
|
+
must now ask for them -- the default response is roughly an order of
|
|
101
|
+
magnitude smaller than the full one on a typical model.
|
|
102
|
+
- `rails_routes` returns `columns` (`name`, `verb`, `path`, `controller`,
|
|
103
|
+
`action`, `engine`) and `rows` instead of one object per route -- the six
|
|
104
|
+
key names were a third of every item's bytes -- and both `rails_routes`
|
|
105
|
+
and `rails_controller` report paths without the `(.:format)` suffix Rails
|
|
106
|
+
appends to most routes.
|
|
107
|
+
- `rails_spec_run`'s `summary` gains `error_count` (how many of
|
|
108
|
+
`failure_count` were errors rather than assertion failures) and
|
|
109
|
+
`assertion_count` (Minitest's assertion total), so a green run answers
|
|
110
|
+
"how many assertions ran?" without `stdout` and a failing run's counts
|
|
111
|
+
match the `0 failures, 5 errors` line beside them. Both are `null` for
|
|
112
|
+
RSpec, which reports neither.
|
|
113
|
+
|
|
3
114
|
## 0.7.0
|
|
4
115
|
|
|
5
116
|
- Add `rails_controller`: reports an `ActionController` controller's
|
data/README.md
CHANGED
|
@@ -57,8 +57,8 @@ MCP client
|
|
|
57
57
|
v
|
|
58
58
|
Coatepec parent (Rails-free)
|
|
59
59
|
`-- private NDJSON --> test worker (Rails "test", booted lazily, kept warm)
|
|
60
|
-
|-- Linux: Process.fork --> isolated RSpec child
|
|
61
|
-
`-- macOS: Process.spawn --> fresh RSpec process (default)
|
|
60
|
+
|-- Linux: Process.fork --> isolated RSpec/Minitest child
|
|
61
|
+
`-- macOS: Process.spawn --> fresh RSpec/Minitest process (default)
|
|
62
62
|
Process.fork, guarded --> opt-in, see Configuration
|
|
63
63
|
```
|
|
64
64
|
|
|
@@ -75,6 +75,11 @@ macos_fork_unsafe_gems: [some_gem] # extends the built-in fork-unsafe denylist
|
|
|
75
75
|
A missing file means every setting takes its default -- this file is never
|
|
76
76
|
required.
|
|
77
77
|
|
|
78
|
+
Tool responses are compact JSON -- no indentation, nothing downstream reads
|
|
79
|
+
it. Set `COATEPEC_PRETTY=1` in the MCP server's `env` (the same place as
|
|
80
|
+
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY` in the JSON example below) to
|
|
81
|
+
pretty-print them when you are reading the sidecar by hand.
|
|
82
|
+
|
|
78
83
|
### macOS fork (experimental, opt-in)
|
|
79
84
|
|
|
80
85
|
On macOS, `rails_spec_run` normally spawns a fresh `bundle exec rspec`
|
|
@@ -125,34 +130,66 @@ a no-op.
|
|
|
125
130
|
|
|
126
131
|
| Tool | Input | Notes |
|
|
127
132
|
|---|---|---|
|
|
128
|
-
| `rails_spec_run` | `paths: string[1..100]`, `example?`, `seed?`, `fail_fast?`, `timeout_seconds?` (1..900, default 120) |
|
|
129
|
-
| `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state |
|
|
133
|
+
| `rails_spec_run` | `paths: string[1..100]`, `example?`, `seed?`, `fail_fast?`, `timeout_seconds?` (1..900, default 120), `include_passing?`, `include_stdout?` (`failures` default, `always`, `never`) | RSpec (`spec/**/*_spec.rb`) or Minitest (`test/**/*_test.rb`), chosen from the paths; isolated per run; output capped at 256 KiB per stream; failure blocks in `stdout` that repeat an earlier block's error verbatim are replaced by a roll-up line naming the tests; returns `summary` counts (`example_count`, `failure_count`, `error_count`, `pending_count`, `assertion_count`, `duration`; the error and assertion counts come from Minitest and are `null` for RSpec) plus only the failed/pending `examples`, pass `include_passing: true` for the full roster (still capped at 500 examples); `stdout` is returned only for failing runs by default; `include_stdout: "always"` keeps it for green runs, `"never"` drops it always (the key stays, `null`); `stderr` is always returned; `child_pid`/`signaled`/`termsig`/`stopsig`/`coredump` appear only when the child did not exit normally (a timeout kill or a crash) |
|
|
134
|
+
| `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state, and the project root (`project_root`) |
|
|
130
135
|
| `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot |
|
|
131
|
-
| `rails_routes` | `query?`, `limit?` (1..200, default
|
|
132
|
-
| `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`) | ActiveRecord models only; columns, associations, validators, enums -- no row data |
|
|
136
|
+
| `rails_routes` | `query?`, `limit?` (1..200, default 100), `offset?`, `engines?` (`exclude` default, `include`, `only`) | Returns `columns` (`name`, `verb`, `path`, `controller`, `action`, `engine`) and `rows` in that order; paths omit the `(.:format)` suffix Rails appends. Case-insensitive filter across name/verb/path/controller/action/engine. Application routes only by default; `engines: "include"` adds the routes of mounted engines (one level deep, paths prefixed with the mount point, `engine` naming the engine class; `null` for an application route), `"only"` returns just those. Every response carries `engines` (the filter applied) and `engines_excluded` (how many routes matching `query` the filter withheld), so nothing is hidden silently. The filter applies after `query` and before paging, so `matched`/`next_offset` describe the kept set. The engine's mount route counts as an application route. Routes Rails marks `internal` are omitted, like `bin/rails routes`. `next_offset` is the offset of the next page, or `null` on the last one |
|
|
137
|
+
| `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`), `fields?` (any of `columns`, `associations`, `validators`, `enums`; omitted or `[]` means none) | Returns `name`, `table_name`, `primary_key`, `abstract_class` and `counts` (the size of each of the four lists, each capped at 200) by default and only the lists named in `fields` -- so a how-many question costs a few dozen bytes and `fields: ["columns"]` asks for the one list you need; ActiveRecord models only; columns, associations, validators, enums -- no row data; validators are de-duplicated by class, attributes and options (a concern and the model body declaring the same validation count once), so the count can be lower than `klass.validators.size`; an array-valued validator option longer than 20 entries (a country-code `inclusion` list, say) is cut to its first 20 with `<option>_count` and `<option>_truncated: true` beside it |
|
|
133
138
|
| `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
|
|
139
|
+
| `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 tests whose status was inconsistent across runs; RSpec or Minitest, chosen from the paths |
|
|
135
140
|
|
|
136
141
|
### Example queries
|
|
137
142
|
|
|
138
143
|
`rails_routes`:
|
|
139
144
|
|
|
140
|
-
- "What are all the routes in this app?" -- `rails_routes()
|
|
145
|
+
- "What are all the routes in this app?" -- `rails_routes()`, the
|
|
146
|
+
application's own; add `engines: "include"` for the mounted engines' too.
|
|
141
147
|
- "What's the URL for widgets?" -- `rails_routes(query: "widget")`. `query` is a
|
|
142
148
|
case-insensitive substring match across name, verb, path, controller, *and*
|
|
143
149
|
action -- not just the path -- so a resource name alone typically returns
|
|
144
150
|
every route for that resource (index/create/new/...); narrow further with
|
|
145
|
-
something like `query: "new_widget"` to hit one route by name.
|
|
151
|
+
something like `query: "new_widget"` to hit one route by name. The call
|
|
152
|
+
already leaves out the admin engine's scaffolding, which is often two thirds
|
|
153
|
+
of every match; the response's `engines_excluded` says how many engine routes
|
|
154
|
+
it held back. Pass `engines: "include"` to see them too, or
|
|
155
|
+
`engines: "only"` for just the engine's.
|
|
146
156
|
- "Which routes accept POST?" -- `rails_routes(query: "POST")`, the same
|
|
147
157
|
substring match applied to the verb column.
|
|
158
|
+
- "Which routes does the Avo engine add?" --
|
|
159
|
+
`rails_routes(query: "Avo::Engine", engines: "only")` -- the substring match
|
|
160
|
+
covers the `engine` column too, so an engine's class name returns exactly the
|
|
161
|
+
routes mounted from it. Application routes always sort before engine routes,
|
|
162
|
+
so an unfiltered listing (with `engines: "include"`) reads the way
|
|
163
|
+
`bin/rails routes` does.
|
|
164
|
+
- An engine route's `name` is relative to its engine, not a top-level url
|
|
165
|
+
helper: the row `["audits", "GET", "/widget_admin/audits",
|
|
166
|
+
"widget_admin/audits", "index", "WidgetAdmin::Engine"]` is reached as
|
|
167
|
+
`widget_admin.audits_path` -- `<mount name>.<name>_path`, where the mount
|
|
168
|
+
name is the `name` of the mount route itself -- and `audits_path` alone
|
|
169
|
+
does not exist on the application.
|
|
170
|
+
- An engine mounted at two paths is listed under both mount points, once per
|
|
171
|
+
prefixed path, whenever engine routes are in scope, since each of those
|
|
172
|
+
paths is a real, reachable URL.
|
|
173
|
+
`bin/rails routes` keys engines by endpoint and so lists such an engine
|
|
174
|
+
only once.
|
|
175
|
+
- Paging: when `next_offset` is not `null`, call again with
|
|
176
|
+
`offset: next_offset` to get the next page.
|
|
148
177
|
|
|
149
178
|
`rails_model`:
|
|
150
179
|
|
|
151
180
|
- "What columns does Widget have, and which are nullable?" --
|
|
152
|
-
`rails_model(name: "Widget")` -- see `columns[].null`,
|
|
153
|
-
`columns[].default`.
|
|
154
|
-
- "What validations and associations does Widget enforce?" --
|
|
181
|
+
`rails_model(name: "Widget", fields: ["columns"])` -- see `columns[].null`,
|
|
182
|
+
`columns[].sql_type`, `columns[].default`.
|
|
183
|
+
- "What validations and associations does Widget enforce?" --
|
|
184
|
+
`rails_model(name: "Widget", fields: ["validators", "associations"])` --
|
|
155
185
|
see `validators` and `associations`.
|
|
186
|
+
- "How many columns, associations, validators and enums does Event have?" --
|
|
187
|
+
`rails_model(name: "Event")` -- see `counts`; ask for the lists with
|
|
188
|
+
`fields` when you need them.
|
|
189
|
+
- A long allow-list is summarised, not enumerated: `validates :country_code,
|
|
190
|
+
inclusion: { in: ISO_CODES }` with 249 codes comes back as `"in"` holding
|
|
191
|
+
the first 20, `"in_count": 249` and `"in_truncated": true`. A list of 20 or
|
|
192
|
+
fewer has no `_count`/`_truncated` siblings.
|
|
156
193
|
- "What happens if I ask about a non-model class, like a controller?" --
|
|
157
194
|
`rails_model(name: "ApplicationController")` raises `not_active_record_model`
|
|
158
195
|
rather than introspecting it (a nonexistent constant raises `model_not_found`
|
|
@@ -162,9 +199,10 @@ a no-op.
|
|
|
162
199
|
|
|
163
200
|
- "What actions does WidgetsController define, and what routes reach them?" --
|
|
164
201
|
`rails_controller(name: "WidgetsController")` -- see `actions[].routes`,
|
|
165
|
-
each with `verb`, `path` (
|
|
166
|
-
|
|
167
|
-
|
|
202
|
+
each with `verb`, `path` (without the `(.:format)` suffix -- byte-identical
|
|
203
|
+
to the same route's `path` from `rails_routes`), `route_name`, and `engine`
|
|
204
|
+
(`null` for an application route, the engine's class name for a route that
|
|
205
|
+
reaches this controller through a mount).
|
|
168
206
|
- "Does this controller have dead code, or a route that will 500?" -- same
|
|
169
207
|
call -- see `unroutable_actions` (action methods no route reaches --
|
|
170
208
|
probably dead code) and `routes_without_action` (action names the route
|
|
@@ -185,6 +223,12 @@ a no-op.
|
|
|
185
223
|
`concerns`: app-defined modules only, whether included directly or
|
|
186
224
|
inherited from a base class; framework modules (`ActionController::Base`
|
|
187
225
|
and everything above it in the ancestor chain) are excluded.
|
|
226
|
+
- Unlike `concerns`, `callbacks[]` is *not* filtered to app code: it is the
|
|
227
|
+
controller's full callback chain, so callbacks Rails itself installs show
|
|
228
|
+
up too -- `verify_authenticity_token` / `verify_same_origin_request` from
|
|
229
|
+
the default forgery protection, and a `"(block)"` entry for macros like
|
|
230
|
+
`allow_browser` that register a Proc. Read the list as "everything that
|
|
231
|
+
runs around an action", not "everything this app wrote".
|
|
188
232
|
- `rails_controller` admits `ActionController::API` controllers as well as
|
|
189
233
|
`ActionController::Base` ones. A malformed constant name raises
|
|
190
234
|
`invalid_controller_name`; a name that doesn't resolve raises
|
|
@@ -192,18 +236,22 @@ a no-op.
|
|
|
192
236
|
`ActionController` descendant (a plain class, a model) raises
|
|
193
237
|
`not_action_controller`.
|
|
194
238
|
|
|
195
|
-
`rails_controller` has
|
|
196
|
-
`rails_routes` already has:
|
|
239
|
+
`rails_controller` has one deliberate limitation:
|
|
197
240
|
|
|
198
241
|
- **Strong parameters are not reported.** `params.require(:widget).permit(:name,
|
|
199
242
|
:size)` exists only as code inside a private method body, never as
|
|
200
243
|
queryable class metadata -- the only way to recover a permit-list is to
|
|
201
244
|
parse source, which this gem does not do (see `ROADMAP.md`'s "Considered
|
|
202
245
|
and set aside" section for why source parsing is out of scope generally).
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
246
|
+
|
|
247
|
+
Routes drawn by an engine mounted in the application are cross-referenced
|
|
248
|
+
like any other, with the mount point on the path and the engine's class name
|
|
249
|
+
in `engine` -- so a controller that lives inside an engine reports its real
|
|
250
|
+
routes rather than listing every action as unroutable. Their `route_name` is
|
|
251
|
+
engine-local, reached as `<mount name>.<route_name>_path`, exactly as in
|
|
252
|
+
`rails_routes`. Expansion goes one level deep: an engine mounted inside
|
|
253
|
+
another engine stays an opaque mount route, the same boundary `bin/rails
|
|
254
|
+
routes` (and therefore `rails_routes`) draws.
|
|
207
255
|
|
|
208
256
|
`rails_spec_flaky_check`:
|
|
209
257
|
|
|
@@ -223,6 +271,8 @@ a no-op.
|
|
|
223
271
|
`rails_spec_run` already has), and each round samples a different subset,
|
|
224
272
|
since execution order varies by design -- for suites this large, narrow
|
|
225
273
|
`paths`/`example` rather than passing a very broad directory selection.
|
|
274
|
+
- `description` is only present when it differs from `id` (RSpec); Minitest
|
|
275
|
+
entries carry `id` only.
|
|
226
276
|
- `statuses[]` only aligns positionally with `rounds[]` when no round
|
|
227
277
|
crashed -- a round whose process itself failed contributes no entry to
|
|
228
278
|
`statuses[]` (though it still appears in `rounds[]`), so treat positional
|
|
@@ -242,6 +292,30 @@ worker). For most apps this is invisible, but if you ever see behavior differ
|
|
|
242
292
|
between Coatepec and your own `bundle exec rspec`, this is the first thing to
|
|
243
293
|
suspect.
|
|
244
294
|
|
|
295
|
+
Minitest through `rails_spec_run`:
|
|
296
|
+
|
|
297
|
+
- "Run this Minitest file" -- `rails_spec_run(paths: ["test/models/widget_test.rb"])`;
|
|
298
|
+
the framework is decided by the path, and there is no separate
|
|
299
|
+
Minitest-named tool. `test/models/widget_test.rb:12` runs the one test
|
|
300
|
+
whose definition spans line 12, and a directory runs every `_test.rb`
|
|
301
|
+
under it.
|
|
302
|
+
- `example:` is a substring match on the test's method name
|
|
303
|
+
(`example: "reaches the"` matches `test_reaches_the_database`), passed to
|
|
304
|
+
Minitest as an escaped regex.
|
|
305
|
+
- Results use RSpec's vocabulary so the shape is identical: a Minitest skip
|
|
306
|
+
is `"pending"`, an error is `"failed"` and counts toward
|
|
307
|
+
`summary.failure_count` -- which is why that number can exceed the
|
|
308
|
+
`failures` Minitest prints in `stdout`; `summary.error_count` says how many
|
|
309
|
+
of them were errors, and `summary.assertion_count` is the assertion total
|
|
310
|
+
Minitest prints; `id` is `ClassName#test_method`.
|
|
311
|
+
- One call may not mix `spec/` and `test/` paths (`mixed_test_frameworks`).
|
|
312
|
+
- Rails' parallel testing is disabled in the child (`PARALLEL_WORKERS=1`), so
|
|
313
|
+
a large directory selection runs serially under the one timeout budget
|
|
314
|
+
rather than forking a worker tree.
|
|
315
|
+
- `file:LINE` works even when the app was generated with `--skip-test` (no
|
|
316
|
+
`rails/test_unit/railtie`) or runs Rails 7.1 with Minitest 6, both of which
|
|
317
|
+
break Rails' own line filtering; Coatepec installs its own.
|
|
318
|
+
|
|
245
319
|
### Restarts
|
|
246
320
|
|
|
247
321
|
If any tool call fails with `sidecar_restart_required`, the target app's
|
|
@@ -262,11 +336,12 @@ it always respawns, even if the current worker looks healthy.
|
|
|
262
336
|
## Security boundary
|
|
263
337
|
|
|
264
338
|
No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
|
|
265
|
-
selectors must resolve inside an allowed spec root (`spec/`, `
|
|
266
|
-
`engines
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
339
|
+
selectors must resolve inside an allowed spec or test root (`spec/`, `test/`,
|
|
340
|
+
and the `packs/*/`, `engines/*/`, `gems/*/` variants of each); absolute
|
|
341
|
+
paths, `..`, symlink escapes, files that are neither `_spec.rb` under a spec
|
|
342
|
+
root nor `_test.rb` under a test root, and more than 100 selectors are
|
|
343
|
+
rejected. RSpec and Minitest still execute application-controlled code; only
|
|
344
|
+
run Coatepec against a trusted checkout.
|
|
270
345
|
|
|
271
346
|
### Compared to Rails Active MCP
|
|
272
347
|
|
|
@@ -279,8 +354,8 @@ sophisticated bypasses of a denylist like that are always possible in
|
|
|
279
354
|
principle.
|
|
280
355
|
|
|
281
356
|
Coatepec takes the opposite approach: there's no eval, console, or SQL
|
|
282
|
-
tool to begin with. `rails_spec_run` only ever executes RSpec
|
|
283
|
-
already exist under the app's own allowed spec roots, and `rails_routes`/
|
|
357
|
+
tool to begin with. `rails_spec_run` only ever executes RSpec or Minitest
|
|
358
|
+
files that already exist under the app's own allowed spec/test roots, and `rails_routes`/
|
|
284
359
|
`rails_model`/`rails_controller` only ever call structured, read-only Rails
|
|
285
360
|
APIs (`Rails.application.routes.routes`, `ActiveRecord` reflection,
|
|
286
361
|
`ActionController` callback/action-method metadata) -- never `eval`,
|
|
@@ -291,9 +366,10 @@ structure without ever handing it a REPL.
|
|
|
291
366
|
|
|
292
367
|
## Compatibility
|
|
293
368
|
|
|
294
|
-
Ruby `>= 3.2`, Rails `>= 7.1, < 8.2
|
|
295
|
-
|
|
296
|
-
|
|
369
|
+
Ruby `>= 3.2`, Rails `>= 7.1, < 8.2`, Minitest 5.x and 6.x (the fixture
|
|
370
|
+
apps pin 6.0.6; the 5.x name-filter flag is unit-tested). CI tests three
|
|
371
|
+
lanes: Rails 8.1 on Linux (primary), Rails 7.1 on Linux (compat), and Rails
|
|
372
|
+
8.1 on macOS (which is where the guarded-fork path above actually forks).
|
|
297
373
|
|
|
298
374
|
## Development
|
|
299
375
|
|
data/ROADMAP.md
CHANGED
|
@@ -4,56 +4,19 @@ Ideas and known future work for Coatepec, roughly in the order they came up.
|
|
|
4
4
|
Nothing here is committed to a release; this is a place to write things down
|
|
5
5
|
before they're designed, not a promise.
|
|
6
6
|
|
|
7
|
-
## Minitest support
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Minitest
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
7
|
+
## Minitest support -- shipped in 0.8.0
|
|
8
|
+
|
|
9
|
+
Shipped through the existing `rails_spec_run`/`rails_spec_flaky_check`
|
|
10
|
+
tools. The open questions resolved as: one tool, dispatching by
|
|
11
|
+
*selector shape* (`test/**/*_test.rb` vs `spec/**/*_spec.rb`) rather
|
|
12
|
+
than by Gemfile, since that is per-request and unambiguous for apps
|
|
13
|
+
with both; structured per-test output comes from an in-process
|
|
14
|
+
`Minitest::AbstractReporter` writing RSpec's JSON shape, so
|
|
15
|
+
`Spec::Result` and `FlakyChecker` were reused unchanged; and Minitest's
|
|
16
|
+
`--seed` is a direct equivalent (16-bit, random order by default), so
|
|
17
|
+
the flaky checker works as-is. See
|
|
18
|
+
`docs/superpowers/specs/2026-09-05-minitest-support-design.md` (local-only)
|
|
19
|
+
for the design and the line-filtering finding.
|
|
57
20
|
|
|
58
21
|
## Get stats on a spec/test run (TestProf / FactoryProf) — designed, implemented, paused
|
|
59
22
|
|
|
@@ -114,16 +77,16 @@ or folds into an existing one, and whether "security audits" specifically
|
|
|
114
77
|
`bin/ci`) are worth exposing as a narrower, separate tool for apps that
|
|
115
78
|
don't use Rails 8.1's `bin/ci` scaffold at all but do have those gems.
|
|
116
79
|
|
|
117
|
-
## Controller introspection
|
|
80
|
+
## Controller introspection -- shipped in 0.7.0
|
|
118
81
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
82
|
+
Shipped as `rails_controller` (`Coatepec::Introspection::Controller`) in
|
|
83
|
+
0.7.0: actions, `before`/`after`/`around` callbacks with their `only`/`except`
|
|
84
|
+
restrictions and `if`/`unless` conditions, included concerns, and a
|
|
85
|
+
cross-reference of every action against `Rails.application.routes`
|
|
86
|
+
(`unroutable_actions` / `routes_without_action`). Strong parameters were
|
|
87
|
+
deliberately left out: a `permit` list exists only as code inside a method
|
|
88
|
+
body, and recovering it would mean source parsing (see "Considered and set
|
|
89
|
+
aside" below). See the README and CHANGELOG for the full contract.
|
|
127
90
|
|
|
128
91
|
## Background job introspection -- built, then parked (PR #14, closed unmerged)
|
|
129
92
|
|
|
@@ -171,9 +134,12 @@ Two constraints worth keeping if this is revisited:
|
|
|
171
134
|
## Cross-file consistency validation
|
|
172
135
|
|
|
173
136
|
Distinct from anything coatepec does today: a tool that checks for drift
|
|
174
|
-
*across* files rather than introspecting one thing at a time -- a
|
|
175
|
-
|
|
176
|
-
|
|
137
|
+
*across* files rather than introspecting one thing at a time -- a
|
|
138
|
+
`belongs_to`/`has_many` referencing a column or table that isn't in the
|
|
139
|
+
schema, that kind of thing. (The route-to-missing-action case is already
|
|
140
|
+
covered per controller by `rails_controller`'s `routes_without_action`,
|
|
141
|
+
shipped in 0.7.0; an app-wide sweep of the whole route table would be the
|
|
142
|
+
cross-file version of that same check.)
|
|
177
143
|
Surfaced while researching prior art (a competing tool does this via
|
|
178
144
|
source-code parsing); would need its own design for how to do it via
|
|
179
145
|
structured Rails APIs instead, consistent with how every other coatepec
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Introspection
|
|
5
|
+
# Caps the array-valued entries of a sanitized validator options hash (see SafeOptions) for rails_model output:
|
|
6
|
+
# 249 country codes just say "allow-list", and the count plus flag beside a cut list keep it self-describing.
|
|
7
|
+
module BoundedOptions
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(options, max)
|
|
11
|
+
options.each_with_object({}) do |(key, value), bounded|
|
|
12
|
+
bounded[key] = value
|
|
13
|
+
next unless value.is_a?(Array) && value.size > max
|
|
14
|
+
|
|
15
|
+
bounded[key] = value.first(max)
|
|
16
|
+
bounded["#{key}_count"] = value.size
|
|
17
|
+
bounded["#{key}_truncated"] = true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "route_entries"
|
|
4
|
+
|
|
3
5
|
module Coatepec
|
|
4
6
|
module Introspection
|
|
5
7
|
# Returns bounded ActionController class metadata for a single controller,
|
|
6
8
|
# for the rails_controller MCP tool. Pure reflection over an already-loaded,
|
|
7
9
|
# already-gated class -- no request dispatch, no action execution, and no
|
|
8
10
|
# evaluation of app-authored callback conditions.
|
|
9
|
-
# rubocop:disable Metrics/ClassLength --
|
|
10
|
-
#
|
|
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.
|
|
11
|
+
# rubocop:disable Metrics/ClassLength -- the route cross-referencing methods serve this class's
|
|
12
|
+
# one responsibility (reflect on one controller); splitting them out would only satisfy a line count.
|
|
15
13
|
class Controller
|
|
16
14
|
NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
17
15
|
MAX_ITEMS = 200
|
|
@@ -101,25 +99,20 @@ module Coatepec
|
|
|
101
99
|
end
|
|
102
100
|
end
|
|
103
101
|
|
|
104
|
-
#
|
|
105
|
-
#
|
|
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.
|
|
102
|
+
# Matches on controller_path, Rails' own key in route defaults ("admin/reports"), through
|
|
103
|
+
# RouteEntries so engine controllers resolve too. Routes with no action (mounts, redirects) are skipped.
|
|
115
104
|
def routes_by_action(klass)
|
|
116
|
-
grouped_routes(klass).transform_values { |list| list.first(MAX_ITEMS).map { |
|
|
105
|
+
grouped_routes(klass).transform_values { |list| list.first(MAX_ITEMS).map { |entry| route_data(entry) } }
|
|
117
106
|
end
|
|
118
107
|
|
|
119
108
|
def grouped_routes(klass)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
109
|
+
entries = controller_entries(klass.controller_path)
|
|
110
|
+
entries.group_by { |entry| entry.route.defaults[:action].to_s }.reject { |action, _| action.empty? }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def controller_entries(path)
|
|
114
|
+
RouteEntries.call(self.class.rails_routes)
|
|
115
|
+
.select { |entry| entry.route.defaults[:controller].to_s == path }
|
|
123
116
|
end
|
|
124
117
|
|
|
125
118
|
# Differenced against the controller's full action_methods set, not the
|
|
@@ -128,19 +121,17 @@ module Coatepec
|
|
|
128
121
|
# have every route whose action fell past the truncation point reported
|
|
129
122
|
# here as a false positive, in the field the README calls the tool's
|
|
130
123
|
# most actionable output. Bounded to MAX_ITEMS like every other
|
|
131
|
-
# collection in this payload; `
|
|
124
|
+
# collection in this payload; `routes_by_action` itself caps each route
|
|
132
125
|
# *list* but not its key count, so this is where that cap belongs.
|
|
133
126
|
def routes_without_action(klass, routes)
|
|
134
127
|
defined_actions = klass.action_methods.map(&:to_s)
|
|
135
128
|
(routes.keys - defined_actions).sort.first(MAX_ITEMS)
|
|
136
129
|
end
|
|
137
130
|
|
|
138
|
-
# path
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def route_data(route)
|
|
143
|
-
{ verb: route.verb.to_s, path: route.path.spec.to_s, route_name: route.name&.to_s }
|
|
131
|
+
# path and engine come off the entry, so they are byte-identical to rails_routes' values for the
|
|
132
|
+
# same route. An engine route's name is engine-local: reach it as `<mount name>.<route_name>_path`.
|
|
133
|
+
def route_data(entry)
|
|
134
|
+
{ verb: entry.route.verb.to_s, path: entry.path, route_name: entry.route.name&.to_s, engine: entry.engine }
|
|
144
135
|
end
|
|
145
136
|
|
|
146
137
|
# Isolated as a class method purely so unit tests can stub it without
|