coatepec 0.8.0 → 0.9.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: 550a60896b3c267b12b5db85b787c52d93b97dd2b95409c8bf78943ea63a27d0
4
- data.tar.gz: '09c6cbbc7c00dd604d2cb5dfbabbb0101aea3cadf5d24070f3254a144bb45012'
3
+ metadata.gz: 72a7dae3c61c36718da0a815ac037c9a50b1a2ae76e282e67d6533131d21c28f
4
+ data.tar.gz: 99c4c1bd193cffe81e49d711f6bedfebd5cb1552ab91f4455ab7b4d31fbabda5
5
5
  SHA512:
6
- metadata.gz: 484557dd0635d21f7669a12b6accab0684e4850e4ac91b6718b733bd23372aceaa5b903f387facb22fdc6b0c2d0a09dfb4805ace67e6b24c66f95c482240d69b
7
- data.tar.gz: e3fcc83c1f752dfba27b1260584b2ee1d0df5707e36163d049015babe269290a2467b3018c0e3696d8d6c4d4fcaf5bf8b2e325a3792ac7d3bcb085cad433e73c
6
+ metadata.gz: 845750211ab96287bccd73b1108451357453925cd0113e92d4251dacca836437783cf78288fa8ca63de5fdbaa772e1ed1f1fc8ce2afbcb346ff9e2f55c1be85a
7
+ data.tar.gz: '04698525bb87f82b589a6d50a61a18edbbd4b8e1520fcdf41d3c9e682b75af664480e4dd5adc2cea54be1f8c6afbe71defc4d8157949e954e90c63d60d9e935c'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ - `rails_spec_run` results always carry `execution_mode`: `fork` (Linux, or
6
+ a macOS guarded fork that ran), `spawn` (macOS with `macos_fork: false`),
7
+ `spawn_fallback` (the guard declined to fork) or `spawn_after_crash` (the
8
+ forked child crashed and the run was retried). The key used to appear
9
+ only when the guarded fork was in play.
10
+ - Behaviour change on macOS: projects that never set `macos_fork: true` now
11
+ fork the warm worker by default, the way it always has on Linux, using
12
+ the guarded fork shipped in 0.7.0 (thread-count and gem-denylist checks,
13
+ a spawn retry if the child crashes). Set `macos_fork: false` in
14
+ `.coatepec.yml` to keep a fresh spawn per call. Measured on a Rails
15
+ 8.2.0.alpha app: 966 ms saved per run, no fallbacks across nine runs.
16
+ `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` is now a recommendation for
17
+ projects that see `spawn_after_crash`, not a requirement.
18
+ - `.coatepec.yml` gains a `defaults` map: `spec_run.include_passing`,
19
+ `spec_run.include_stdout`, `spec_run.timeout_seconds` and `routes.engines`
20
+ set project-wide defaults for those inputs. A call argument always wins;
21
+ an omitted key means the built-in default; an unknown key or an
22
+ out-of-range value fails the call with `invalid_config` naming it. The
23
+ file is re-read on every call.
24
+ - `rails_runtime_status` reports `spec_strategy` (`fork`, `guarded_fork` or
25
+ `spawn`), `fallbacks` (how many guarded-fork runs fell back to spawn in
26
+ this worker's lifetime; `null` unless the guarded fork is in use) and
27
+ `defaults` (the effective `rails_spec_run` and `rails_routes` defaults
28
+ after `.coatepec.yml`), so an agent can see every default in one call.
29
+ `fallbacks` also counts `rails_spec_flaky_check` rounds, which run through
30
+ the same strategy.
31
+
3
32
  ## 0.8.0
4
33
 
5
34
  - Tool responses are now compact JSON rather than pretty-printed (22-32%
data/README.md CHANGED
@@ -58,8 +58,8 @@ MCP client
58
58
  Coatepec parent (Rails-free)
59
59
  `-- private NDJSON --> test worker (Rails "test", booted lazily, kept warm)
60
60
  |-- Linux: Process.fork --> isolated RSpec/Minitest child
61
- `-- macOS: Process.spawn --> fresh RSpec/Minitest process (default)
62
- Process.fork, guarded --> opt-in, see Configuration
61
+ `-- macOS: Process.fork, guarded --> isolated RSpec/Minitest child (default)
62
+ Process.spawn --> fresh process, see Configuration (macos_fork: false)
63
63
  ```
64
64
 
65
65
  ## Configuration
@@ -68,28 +68,43 @@ An optional `.coatepec.yml` at the target Rails app's root enables
68
68
  per-project settings:
69
69
 
70
70
  ```yaml
71
- macos_fork: true # opt into forking on macOS (see below)
71
+ macos_fork: false # opt out of forking on macOS (default true; see below)
72
72
  macos_fork_unsafe_gems: [some_gem] # extends the built-in fork-unsafe denylist
73
+
74
+ defaults:
75
+ spec_run:
76
+ include_passing: true # true or false (default false)
77
+ include_stdout: always # failures (default), always, never
78
+ timeout_seconds: 300 # 1..900 (default 120)
79
+ routes:
80
+ engines: include # include, exclude (default), only
73
81
  ```
74
82
 
75
83
  A missing file means every setting takes its default -- this file is never
76
84
  required.
77
85
 
86
+ `defaults` sets project-wide values for those four inputs; a call argument
87
+ always wins, an omitted key falls back to the built-in default, and an
88
+ unknown key or value fails the call with `invalid_config` naming it. The
89
+ file is re-read on every call, so edits apply immediately.
90
+ `defaults.spec_run` applies to `rails_spec_run` only; `rails_spec_flaky_check`
91
+ keeps its own per-round budget.
92
+
78
93
  Tool responses are compact JSON -- no indentation, nothing downstream reads
79
94
  it. Set `COATEPEC_PRETTY=1` in the MCP server's `env` (the same place as
80
95
  `OBJC_DISABLE_INITIALIZE_FORK_SAFETY` in the JSON example below) to
81
96
  pretty-print them when you are reading the sidecar by hand.
82
97
 
83
- ### macOS fork (experimental, opt-in)
98
+ ### macOS fork (default since 0.9.0)
84
99
 
85
- On macOS, `rails_spec_run` normally spawns a fresh `bundle exec rspec`
86
- process per call, re-booting Rails every time -- the warm-worker speedup
87
- described above only applies on Linux by default. Setting `macos_fork:
88
- true` lets Coatepec attempt `Process.fork` on macOS too, reusing the warm
89
- boot the way Linux does.
100
+ On macOS, `rails_spec_run` forks the warm worker for each call, reusing the
101
+ boot the way it always has on Linux. Setting `macos_fork: false` in
102
+ `.coatepec.yml` opts the project out: every call then spawns a fresh
103
+ `bundle exec rspec` process and re-boots Rails, so only the Linux lane
104
+ gets the warm-worker speedup described above.
90
105
 
91
- This is opt-in because forking a process with native extensions loaded
92
- isn't universally safe. Before each fork, Coatepec checks the worker's
106
+ The fork is guarded, because forking a process with native extensions
107
+ loaded isn't universally safe. Before each fork, Coatepec checks the worker's
93
108
  live thread count against its post-boot baseline and its loaded gems
94
109
  against a denylist, falling back to a fresh spawn for that one call if
95
110
  either check looks risky. The built-in denylist ships empty -- no single
@@ -98,17 +113,17 @@ thread-count-only until a project adds its own
98
113
  `macos_fork_unsafe_gems`. If a fork is attempted and the child crashes
99
114
  anyway, Coatepec transparently retries via spawn and returns that result
100
115
  -- fork stays enabled for later calls. Every `rails_spec_run` result
101
- includes an `execution_mode` field (`fork`, `spawn_fallback`, or
102
- `spawn_after_crash`) so you can see which path actually ran for a given
116
+ includes an `execution_mode` field (`fork`, `spawn`, `spawn_fallback`,
117
+ or `spawn_after_crash`) so you can see which path actually ran for a given
103
118
  call; `spawn_after_crash` results also carry the crashed fork's own stderr
104
119
  under `crashed_fork_stderr` so the crash can be diagnosed.
105
120
 
106
- `macos_fork: true` also effectively requires
107
- `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` in Coatepec's own environment.
108
- Without it, a forked child that touches an Objective-C-initialized class
109
- aborts -- Coatepec retries via spawn, so it degrades silently to the slow
110
- path (no crash, no error surfaced) rather than failing loudly, and you
111
- simply never get the speedup. Set it on the MCP server process itself:
121
+ `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` in Coatepec's own environment is
122
+ recommended if you ever see `spawn_after_crash`. Without it, a forked child
123
+ that touches an Objective-C-initialized class aborts -- Coatepec retries via
124
+ spawn, so it degrades to the slow path for that call (no crash, no error
125
+ surfaced) rather than failing loudly, and you simply never get the speedup.
126
+ Set it on the MCP server process itself:
112
127
 
113
128
  ```json
114
129
  {
@@ -130,10 +145,10 @@ a no-op.
130
145
 
131
146
  | Tool | Input | Notes |
132
147
  |---|---|---|
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`) |
135
- | `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot |
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 |
148
+ | `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`) (all three default-able in `.coatepec.yml`) | 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); every result carries `execution_mode` (`fork`, `spawn`, `spawn_fallback`, `spawn_after_crash`) |
149
+ | `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state, the project root, `spec_strategy` (`fork`, `guarded_fork`, `spawn`), `fallbacks` (guarded-fork runs that fell back to spawn; `null` unless guarded) and `defaults` (the effective `rails_spec_run`/`rails_routes` defaults after `.coatepec.yml`) |
150
+ | `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot; returns the fresh worker's status, including `spec_strategy` and `fallbacks` |
151
+ | `rails_routes` | `query?`, `limit?` (1..200, default 100), `offset?`, `engines?` (`exclude` default, `include`, `only`) (default-able in `.coatepec.yml`) | 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
152
  | `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 |
138
153
  | `rails_controller` | `name` (constant path, e.g. `WidgetsController` or `Admin::ReportsController`) | Actions, action callbacks, concerns, and the routes reaching each action -- no request dispatch |
139
154
  | `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 |
@@ -161,6 +176,18 @@ a no-op.
161
176
  routes mounted from it. Application routes always sort before engine routes,
162
177
  so an unfiltered listing (with `engines: "include"`) reads the way
163
178
  `bin/rails routes` does.
179
+ - `engines_excluded` is a correctness signal, not only a saving. Engine
180
+ routes print their paths relative to the mount point, so
181
+ `bin/rails routes -g admin` cannot find an admin engine's routes at all: it
182
+ matches the mount line and answers "one admin route" with no hint that
183
+ anything is missing. `rails_routes(query: "admin")` returns that one row
184
+ plus `engines_excluded: 243`, so the reader knows the rest exists.
185
+ - Opting in is the expensive path, and it paginates. A broad query with
186
+ `engines: "include"` on an app with a mounted admin engine costs several
187
+ times the default response and can still stop at the 100-row page of a
188
+ larger match (`next_offset` says so), where the default returned the
189
+ application's routes complete. Reach for `engines: "only"` with the
190
+ engine's class name when the engine's routes are the question.
164
191
  - An engine route's `name` is relative to its engine, not a top-level url
165
192
  helper: the row `["audits", "GET", "/widget_admin/audits",
166
193
  "widget_admin/audits", "index", "WidgetAdmin::Engine"]` is reached as
@@ -369,7 +396,7 @@ structure without ever handing it a REPL.
369
396
  Ruby `>= 3.2`, Rails `>= 7.1, < 8.2`, Minitest 5.x and 6.x (the fixture
370
397
  apps pin 6.0.6; the 5.x name-filter flag is unit-tested). CI tests three
371
398
  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).
399
+ 8.1 on macOS (the lane that exercises the guarded-fork path above).
373
400
 
374
401
  ## Development
375
402
 
data/ROADMAP.md CHANGED
@@ -39,8 +39,9 @@ root-cause writeup and the fix options considered (forcing `SpawnStrategy`
39
39
  for profiled runs specifically, vs. detecting and raising a clear error on
40
40
  non-spawn strategies, vs. a Linux-only opt-out config knob). Paused
41
41
  specifically to wait for real signal on how coatepec is actually used
42
- (Linux vs. macOS, `macos_fork` adoption) before picking a fix, rather than
43
- guessing.
42
+ (Linux vs. macOS, `macos_fork` adoption) -- `macos_fork` is the default from
43
+ 0.9.0, so the fork path is now the common case on both platforms -- before
44
+ picking a fix, rather than guessing.
44
45
 
45
46
  ## Run Rails 8.1's built-in CI (`bin/ci`)
46
47
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../coatepec"
4
+
5
+ module Coatepec
6
+ module MCP
7
+ # Fills a tool's omitted inputs: call argument first, then .coatepec.yml, then the built-in.
8
+ module Defaults
9
+ BUILTIN = {
10
+ spec_run: { include_passing: false, include_stdout: "failures", timeout_seconds: 120 },
11
+ routes: { engines: Introspection::Routes::DEFAULT_ENGINES }
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ # Re-reads the file each call: a few hundred bytes, and an edit then applies to the next call.
17
+ def resolve(tool, project_root, **given)
18
+ configured = ProjectConfig.new(project_root).defaults_for(tool)
19
+ BUILTIN.fetch(tool).to_h do |key, builtin|
20
+ [key, given[key].nil? ? configured.fetch(key, builtin) : given[key]]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -33,18 +33,21 @@ module Coatepec
33
33
  "paths; there is no separate Minitest tool#{RSPEC_VOCABULARY}" \
34
34
  "; returns only failed and pending examples unless include_passing is true" \
35
35
  "; failure blocks in stdout that repeat an earlier error are rolled up into one line" \
36
- "; stdout is returned only for failing runs unless include_stdout is \"always\" or \"never\""
36
+ "; stdout is returned only for failing runs unless include_stdout is \"always\" or \"never\"" \
37
+ "; defaults for include_passing, include_stdout and timeout_seconds can be set project-wide " \
38
+ "in .coatepec.yml"
37
39
  annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
38
40
  input_schema(**INPUT_SCHEMA)
39
41
 
40
42
  class << self
41
- def call(paths:, server_context:, example: nil, seed: nil, fail_fast: false, timeout_seconds: 120,
42
- include_passing: false, include_stdout: "failures")
43
+ def call(paths:, server_context:, example: nil, seed: nil, fail_fast: false, timeout_seconds: nil,
44
+ include_passing: nil, include_stdout: nil)
43
45
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
- data = server_context[:worker_manager].run_spec(
45
- paths: paths, example: example, seed: seed, fail_fast: fail_fast, timeout_seconds: timeout_seconds,
46
- include_passing: include_passing, include_stdout: include_stdout
47
- )
46
+ inputs = Defaults.resolve(:spec_run, server_context[:project_root], timeout_seconds: timeout_seconds,
47
+ include_passing: include_passing,
48
+ include_stdout: include_stdout)
49
+ data = server_context[:worker_manager].run_spec(paths: paths, example: example, seed: seed,
50
+ fail_fast: fail_fast, **inputs)
48
51
  Response.ok(data: data, meta: Response.meta(started_at))
49
52
  rescue Coatepec::Error => e
50
53
  Response.error(e)
@@ -53,21 +56,28 @@ module Coatepec
53
56
  end
54
57
 
55
58
  # The `rails_runtime_status` MCP tool: reports the test worker's
56
- # Ruby/Rails versions, PID, boot_id, lifecycle state and the project
57
- # root. Worker::Server#handle boots the Rails runtime before dispatching
58
- # any command, so the first call to this tool starts (and blocks on) a
59
- # full Rails boot just like a spec run.
59
+ # Ruby/Rails versions, PID, boot_id, lifecycle state, the spec strategy
60
+ # and its fallback count, the project root and the effective tool
61
+ # defaults. Worker::Server#handle boots the Rails runtime before
62
+ # dispatching any command, so the first call to this tool starts (and
63
+ # blocks on) a full Rails boot just like a spec run.
60
64
  class RuntimeStatusTool < ::MCP::Tool
61
65
  tool_name "rails_runtime_status"
62
- description "Report the Coatepec test worker's identity and boot status " \
63
- "(boots the warm worker if it is not up yet); includes the project root as project_root"
66
+ description "Report the Coatepec test worker's identity and boot status (boots the warm worker if it is " \
67
+ "not up yet); includes project_root, spec_strategy (fork, guarded_fork or spawn), fallbacks " \
68
+ "(how many guarded-fork runs fell back to spawn; null unless guarded_fork) and defaults (the " \
69
+ "effective rails_spec_run and rails_routes defaults after .coatepec.yml)"
64
70
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
65
71
  input_schema(properties: {}, required: [], additionalProperties: false)
66
72
 
67
73
  class << self
68
74
  def call(server_context:)
69
75
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
- data = server_context[:worker_manager].status.merge(project_root: server_context[:project_root])
76
+ root = server_context[:project_root]
77
+ data = server_context[:worker_manager].status.merge(
78
+ project_root: root,
79
+ defaults: { spec_run: Defaults.resolve(:spec_run, root), routes: Defaults.resolve(:routes, root) }
80
+ )
71
81
  Response.ok(data: data, meta: Response.meta(started_at))
72
82
  rescue Coatepec::Error => e
73
83
  Response.error(e)
@@ -147,7 +157,8 @@ module Coatepec
147
157
  "engine field (null for an application route; query also matches that field); returns columns " \
148
158
  "(name, verb, path, controller, action, engine) and up to limit rows (default 100) in that " \
149
159
  "order, paths without the (.:format) suffix Rails appends, with next_offset -- the offset to " \
150
- "pass back for the next page, null on the last one"
160
+ "pass back for the next page, null on the last one" \
161
+ "; the engines default can be set project-wide in .coatepec.yml"
151
162
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
152
163
  input_schema(
153
164
  properties: {
@@ -161,9 +172,10 @@ module Coatepec
161
172
  )
162
173
 
163
174
  class << self
164
- def call(server_context:, query: nil, limit: 100, offset: 0, engines: Introspection::Routes::DEFAULT_ENGINES)
175
+ def call(server_context:, query: nil, limit: 100, offset: 0, engines: nil)
165
176
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
166
- data = server_context[:worker_manager].routes(query: query, limit: limit, offset: offset, engines: engines)
177
+ inputs = Defaults.resolve(:routes, server_context[:project_root], engines: engines)
178
+ data = server_context[:worker_manager].routes(query: query, limit: limit, offset: offset, **inputs)
167
179
  Response.ok(data: data, meta: Response.meta(started_at))
168
180
  rescue Coatepec::Error => e
169
181
  Response.error(e)
data/lib/coatepec/mcp.rb CHANGED
@@ -9,6 +9,7 @@ end
9
9
 
10
10
  require "coatepec"
11
11
  require_relative "mcp/response"
12
+ require_relative "mcp/defaults"
12
13
  require_relative "mcp/tools"
13
14
 
14
15
  module Coatepec
@@ -7,12 +7,28 @@ module Coatepec
7
7
  # project root. A missing file means every setting takes its default --
8
8
  # this file has never been required for Coatepec to work.
9
9
  class ProjectConfig
10
+ # Mirrors the MCP input schemas so a value the file accepts is one the tool accepts.
11
+ DEFAULT_KEYS = {
12
+ "spec_run" => {
13
+ "include_passing" => { boolean: true },
14
+ "include_stdout" => { enum: %w[failures always never] },
15
+ "timeout_seconds" => { range: 1..900 }
16
+ },
17
+ "routes" => { "engines" => { enum: %w[include exclude only] } }
18
+ }.freeze
19
+
10
20
  def initialize(root)
11
21
  @data = load(root)
22
+ @defaults = validate_defaults!(@data["defaults"] || {}) # a bare `defaults:` key means none
23
+ end
24
+
25
+ def defaults_for(tool)
26
+ @defaults.fetch(tool.to_s, {}).transform_keys(&:to_sym)
12
27
  end
13
28
 
29
+ # Forking is the macOS default since 0.9.0; `macos_fork: false` opts a project out.
14
30
  def macos_fork?
15
- !!@data["macos_fork"]
31
+ @data.fetch("macos_fork", true) ? true : false
16
32
  end
17
33
 
18
34
  def macos_fork_unsafe_gems
@@ -21,6 +37,29 @@ module Coatepec
21
37
 
22
38
  private
23
39
 
40
+ def validate_defaults!(defaults)
41
+ invalid!("defaults must be a mapping of tool names") unless defaults.is_a?(Hash)
42
+ defaults.each do |tool, keys|
43
+ rules = DEFAULT_KEYS[tool.to_s] || invalid!("defaults.#{tool} is not a configurable tool")
44
+ invalid!("defaults.#{tool} must be a mapping") unless keys.is_a?(Hash)
45
+ keys.each { |key, value| validate_default!(tool, key, value, rules[key.to_s]) }
46
+ end
47
+ defaults
48
+ end
49
+
50
+ def validate_default!(tool, key, value, rule)
51
+ invalid!("defaults.#{tool}.#{key} is not a configurable input") unless rule
52
+ ok = if rule[:boolean] then [true, false].include?(value)
53
+ elsif rule[:enum] then rule[:enum].include?(value)
54
+ else value.is_a?(Integer) && rule[:range].cover?(value)
55
+ end
56
+ invalid!("defaults.#{tool}.#{key}: #{value.inspect} is not allowed") unless ok
57
+ end
58
+
59
+ def invalid!(message)
60
+ raise Coatepec::Error.new(:invalid_config, "Invalid .coatepec.yml: #{message}")
61
+ end
62
+
24
63
  def load(root)
25
64
  path = File.join(root, ".coatepec.yml")
26
65
  return {} unless File.exist?(path)
@@ -8,6 +8,8 @@ module Coatepec
8
8
  class ForkStrategy < ProcessStrategy
9
9
  private
10
10
 
11
+ def execution_mode = "fork"
12
+
11
13
  def start(full_args, out_w, err_w, json_path)
12
14
  Process.fork do
13
15
  Process.setpgid(0, 0)
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Coatepec
4
4
  module Spec
5
- # Opt-in macOS fork strategy: attempts Process.fork like ForkStrategy
6
- # (reusing the warm worker's boot), but only after two cheap guard
7
- # checks pass, and transparently falls back to a fresh SpawnStrategy
8
- # run -- for this call only -- when a guard fails or the forked child
9
- # crashes. See docs/superpowers/specs/2026-07-31-macos-guarded-fork-design.md.
5
+ # The macOS fork strategy (the default since 0.9.0; macos_fork: false
6
+ # opts out): attempts Process.fork like ForkStrategy (reusing the warm
7
+ # worker's boot), but only after two cheap guard checks pass, and
8
+ # transparently falls back to a fresh SpawnStrategy run -- for this call
9
+ # only -- when a guard fails or the forked child crashes. See
10
+ # docs/superpowers/specs/2026-07-31-macos-guarded-fork-design.md.
10
11
  class GuardedForkStrategy < ForkStrategy
11
12
  # Intentionally empty at ship time: the one documented crash this
12
13
  # guards against didn't name a specific culprit gem, just "something
@@ -52,11 +53,11 @@ module Coatepec
52
53
  # Process.fork itself failed (Errno::EAGAIN/ENOMEM under
53
54
  # process-table pressure), so no child was ever produced -- from the
54
55
  # caller's side that is indistinguishable from a failed guard, hence
55
- # the same mode. Opting into macos_fork must never surface an error
56
- # that plain SpawnStrategy wouldn't have.
56
+ # the same mode. Forking on macOS must never surface an error that
57
+ # plain SpawnStrategy wouldn't have.
57
58
  return fallback_result(args, timeout_seconds, "spawn_fallback", result_options)
58
59
  end
59
- return result.merge(execution_mode: "fork") unless crashed?(result)
60
+ return result unless crashed?(result)
60
61
 
61
62
  retry_after_crash(args, timeout_seconds, started_at, result, result_options)
62
63
  end
@@ -29,10 +29,16 @@ module Coatepec
29
29
 
30
30
  reap(pid, [out_r, err_r], timeout_seconds, json_path,
31
31
  { include_passing: include_passing, include_stdout: include_stdout })
32
+ .merge(execution_mode: execution_mode)
32
33
  end
33
34
 
34
35
  private
35
36
 
37
+ # Every result says how it ran, so an absent key can never be mistaken for "the guard fell back".
38
+ def execution_mode
39
+ raise NotImplementedError, "#{self.class} must implement #execution_mode"
40
+ end
41
+
36
42
  # Subclasses start a process and return its pid; the test framework's
37
43
  # own output must be wired to out_w/err_w. json_path is where the
38
44
  # adapter's structured per-example output must land.
@@ -4,9 +4,9 @@ module Coatepec
4
4
  module Spec
5
5
  # Validates a `rails_spec_run` request's paths, picks the RSpec or
6
6
  # Minitest adapter from their shape, builds the CLI args, and delegates
7
- # to the platform-appropriate process strategy (fork on Linux, spawn on
8
- # macOS, or a guarded fork on macOS when the project opts in via
9
- # .coatepec.yml).
7
+ # to the platform-appropriate process strategy (fork on Linux, and a
8
+ # guarded fork on macOS unless the project opts out via .coatepec.yml,
9
+ # which selects a fresh spawn per call).
10
10
  class Runner
11
11
  DEFAULT_TIMEOUT = 120
12
12
 
@@ -24,8 +24,17 @@ module Coatepec
24
24
  adapter.require_framework!
25
25
  args = adapter.build_args(validated[:selectors], example, seed, fail_fast)
26
26
 
27
- strategy_class.new(@project_root, adapter: adapter, project: @project, rails_runtime: @rails_runtime)
28
- .run(args, timeout_seconds, include_passing: include_passing, include_stdout: include_stdout)
27
+ result = strategy_class
28
+ .new(@project_root, adapter: adapter, project: @project, rails_runtime: @rails_runtime)
29
+ .run(args, timeout_seconds, include_passing: include_passing, include_stdout: include_stdout)
30
+ @rails_runtime&.record_execution_mode(result[:execution_mode])
31
+ result
32
+ end
33
+
34
+ # Reported by rails_runtime_status so a caller can see which path a run will take.
35
+ def strategy_name
36
+ { ForkStrategy => "fork", GuardedForkStrategy => "guarded_fork", SpawnStrategy => "spawn" }
37
+ .fetch(strategy_class)
29
38
  end
30
39
 
31
40
  private
@@ -49,9 +58,8 @@ module Coatepec
49
58
  end
50
59
  end
51
60
 
52
- # Reading @project.config here means an invalid .coatepec.yml only
53
- # raises :invalid_config on macOS -- the Linux branch never touches it.
54
- # Accepted asymmetry: the file exists to configure this branch.
61
+ # Forking is the default; only an explicit `macos_fork: false` spawns. Only the macOS
62
+ # branch reads the file here; the MCP layer reads it on every call for `defaults`.
55
63
  def macos_strategy_class
56
64
  @project.config.macos_fork? ? GuardedForkStrategy : SpawnStrategy
57
65
  end
@@ -9,6 +9,8 @@ module Coatepec
9
9
  class SpawnStrategy < ProcessStrategy
10
10
  private
11
11
 
12
+ def execution_mode = "spawn"
13
+
12
14
  def start(full_args, out_w, err_w, json_path)
13
15
  env, argv = @adapter.spawn_command(full_args, json_path)
14
16
  Process.spawn(env, *argv, chdir: @project_root, out: out_w, err: err_w, pgroup: true)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coatepec
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -8,17 +8,26 @@ module Coatepec
8
8
  # per worker process and reports its identity (pid, boot_id, versions,
9
9
  # lifecycle state) for rails_runtime_status.
10
10
  class RailsRuntime
11
- attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count
11
+ attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count,
12
+ :fallback_count
13
+
14
+ FALLBACK_MODES = %w[spawn_fallback spawn_after_crash].freeze
12
15
 
13
16
  def initialize(project_root)
14
17
  @project_root = project_root
15
18
  @booted = false
19
+ @fallback_count = 0
16
20
  end
17
21
 
18
22
  def booted?
19
23
  @booted
20
24
  end
21
25
 
26
+ # A guarded fork that declined or crashed ran via spawn; rails_runtime_status reports how often.
27
+ def record_execution_mode(mode)
28
+ @fallback_count += 1 if FALLBACK_MODES.include?(mode)
29
+ end
30
+
22
31
  def boot!
23
32
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
33
  ENV["RAILS_ENV"] = "test"
@@ -40,7 +40,7 @@ module Coatepec
40
40
 
41
41
  def execute_command(command, args)
42
42
  case command
43
- when "status" then @runtime.status
43
+ when "status" then handle_status
44
44
  when "spec_run" then handle_spec_run(args)
45
45
  when "flaky_check" then handle_flaky_check(args)
46
46
  when "routes" then handle_routes(args)
@@ -51,6 +51,12 @@ module Coatepec
51
51
  end
52
52
  end
53
53
 
54
+ # spec_strategy and fallbacks are the worker's to report: it owns the platform check and the counter.
55
+ def handle_status
56
+ name = Spec::Runner.new(@project_root, rails_runtime: @runtime).strategy_name
57
+ @runtime.status.merge(spec_strategy: name, fallbacks: name == "guarded_fork" ? @runtime.fallback_count : nil)
58
+ end
59
+
54
60
  def handle_spec_run(args)
55
61
  Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
56
62
  end
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Enrique Mogollan
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-14 00:00:00.000000000 Z
10
+ date: 2026-09-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -75,6 +75,7 @@ files:
75
75
  - lib/coatepec/introspection/routes.rb
76
76
  - lib/coatepec/introspection/safe_options.rb
77
77
  - lib/coatepec/mcp.rb
78
+ - lib/coatepec/mcp/defaults.rb
78
79
  - lib/coatepec/mcp/response.rb
79
80
  - lib/coatepec/mcp/tools.rb
80
81
  - lib/coatepec/project.rb