coatepec 0.7.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/CHANGELOG.md +140 -0
  4. data/README.md +153 -50
  5. data/ROADMAP.md +31 -64
  6. data/lib/coatepec/introspection/bounded_options.rb +22 -0
  7. data/lib/coatepec/introspection/controller.rb +19 -28
  8. data/lib/coatepec/introspection/model.rb +33 -43
  9. data/lib/coatepec/introspection/model_resolver.rb +32 -0
  10. data/lib/coatepec/introspection/route_entries.rb +77 -0
  11. data/lib/coatepec/introspection/routes.rb +70 -20
  12. data/lib/coatepec/mcp/defaults.rb +25 -0
  13. data/lib/coatepec/mcp/response.rb +16 -9
  14. data/lib/coatepec/mcp/tools.rb +100 -93
  15. data/lib/coatepec/mcp.rb +3 -2
  16. data/lib/coatepec/project.rb +11 -1
  17. data/lib/coatepec/project_config.rb +40 -1
  18. data/lib/coatepec/spec/failure_collapser.rb +111 -0
  19. data/lib/coatepec/spec/flaky_checker.rb +3 -1
  20. data/lib/coatepec/spec/fork_strategy.rb +5 -12
  21. data/lib/coatepec/spec/guarded_fork_strategy.rb +20 -18
  22. data/lib/coatepec/spec/path_policy.rb +45 -12
  23. data/lib/coatepec/spec/process_strategy.rb +28 -17
  24. data/lib/coatepec/spec/result.rb +61 -19
  25. data/lib/coatepec/spec/rspec_adapter.rb +58 -0
  26. data/lib/coatepec/spec/runner.rb +33 -27
  27. data/lib/coatepec/spec/spawn_strategy.rb +9 -8
  28. data/lib/coatepec/test_unit/adapter.rb +132 -0
  29. data/lib/coatepec/test_unit/child_entry.rb +33 -0
  30. data/lib/coatepec/test_unit/json_reporter.rb +107 -0
  31. data/lib/coatepec/test_unit/line_filtering.rb +46 -0
  32. data/lib/coatepec/version.rb +1 -1
  33. data/lib/coatepec/worker/rails_runtime.rb +10 -1
  34. data/lib/coatepec/worker/server.rb +9 -2
  35. data/lib/coatepec/worker_manager.rb +8 -6
  36. data/lib/coatepec.rb +5 -0
  37. metadata +17 -5
@@ -2,67 +2,83 @@
2
2
 
3
3
  module Coatepec
4
4
  module MCP
5
- # The `rails_spec_run` MCP tool: runs targeted RSpec examples against
6
- # the warm test worker and returns a structured pass/fail result.
5
+ # The `rails_spec_run` MCP tool: runs targeted RSpec examples or Minitest
6
+ # tests (chosen from the selector paths) against the warm test worker and
7
+ # returns a structured pass/fail result.
7
8
  class SpecRunTool < ::MCP::Tool
8
- tool_name "rails_spec_run"
9
- description "Run targeted RSpec examples against a warm, isolated Rails test worker"
10
- annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
11
- input_schema(
9
+ INPUT_SCHEMA = {
12
10
  properties: {
13
11
  paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 },
14
12
  example: { type: %w[string null] },
15
13
  seed: { type: %w[integer null], minimum: 0, maximum: 65_535 },
16
14
  fail_fast: { type: "boolean" },
17
- timeout_seconds: { type: "integer", minimum: 1, maximum: 900 }
15
+ timeout_seconds: { type: "integer", minimum: 1, maximum: 900 },
16
+ include_passing: { type: "boolean" },
17
+ include_stdout: { type: "string", enum: %w[failures always never] }
18
18
  },
19
19
  required: ["paths"],
20
20
  additionalProperties: false
21
- )
21
+ }.freeze
22
+
23
+ # Kept a constant so the vocabulary clause stays readable next to the rest of the description.
24
+ RSPEC_VOCABULARY =
25
+ "; results use RSpec vocabulary for both frameworks: a Minitest error is status failed and is " \
26
+ "counted in summary.failure_count (so it can exceed the failures number Minitest prints in " \
27
+ "stdout; summary.error_count says how many of those were errors) and a skip is pending" \
28
+ "; summary.assertion_count is Minitest's assertion total, null for RSpec, as is error_count"
29
+
30
+ tool_name "rails_spec_run"
31
+ description "Run targeted RSpec examples (spec/**/*_spec.rb) or Minitest tests (test/**/*_test.rb) " \
32
+ "against a warm, isolated Rails test worker; the framework is chosen from the selector " \
33
+ "paths; there is no separate Minitest tool#{RSPEC_VOCABULARY}" \
34
+ "; returns only failed and pending examples unless include_passing is true" \
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\"" \
37
+ "; defaults for include_passing, include_stdout and timeout_seconds can be set project-wide " \
38
+ "in .coatepec.yml"
39
+ annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
40
+ input_schema(**INPUT_SCHEMA)
22
41
 
23
42
  class << self
24
- # rubocop:disable Metrics/ParameterLists -- mirrors the tool's own input_schema
25
- # (paths/example/seed/fail_fast/timeout_seconds) plus the MCP-framework-injected
26
- # server_context; splitting it would fight the ::MCP::Tool#call contract.
27
- def call(paths:, server_context:, example: nil, seed: nil, fail_fast: false, timeout_seconds: 120)
28
- # rubocop:enable Metrics/ParameterLists
43
+ def call(paths:, server_context:, example: nil, seed: nil, fail_fast: false, timeout_seconds: nil,
44
+ include_passing: nil, include_stdout: nil)
29
45
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
- data = server_context[:worker_manager].run_spec(
31
- paths: paths, example: example, seed: seed, fail_fast: fail_fast, timeout_seconds: timeout_seconds
32
- )
33
- Response.ok(data: data, meta: meta_for(server_context, started_at))
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)
51
+ Response.ok(data: data, meta: Response.meta(started_at))
34
52
  rescue Coatepec::Error => e
35
53
  Response.error(e)
36
54
  end
37
-
38
- private
39
-
40
- def meta_for(server_context, started_at)
41
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
42
- { project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
43
- end
44
55
  end
45
56
  end
46
57
 
47
- # The `rails_runtime_status` MCP tool: reports the test worker's Ruby/Rails
48
- # versions, PID, boot_id, and lifecycle state. Worker::Server#handle boots
49
- # the Rails runtime before dispatching any command, so the first call to
50
- # this tool starts (and blocks on) a full Rails boot just like a spec run.
58
+ # The `rails_runtime_status` MCP tool: reports the test worker's
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.
51
64
  class RuntimeStatusTool < ::MCP::Tool
52
65
  tool_name "rails_runtime_status"
53
- description "Report the Coatepec test worker's identity and boot status " \
54
- "(boots the warm worker if it is not up yet)"
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)"
55
70
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
56
71
  input_schema(properties: {}, required: [], additionalProperties: false)
57
72
 
58
73
  class << self
59
74
  def call(server_context:)
60
75
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
- data = server_context[:worker_manager].status
62
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
63
- Response.ok(data: data,
64
- meta: { project_root: server_context[:project_root], environment: "test",
65
- duration_ms: duration_ms })
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
+ )
81
+ Response.ok(data: data, meta: Response.meta(started_at))
66
82
  rescue Coatepec::Error => e
67
83
  Response.error(e)
68
84
  end
@@ -84,27 +100,20 @@ module Coatepec
84
100
  def call(server_context:)
85
101
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
102
  data = server_context[:worker_manager].restart!
87
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
88
- Response.ok(data: data,
89
- meta: { project_root: server_context[:project_root], environment: "test",
90
- duration_ms: duration_ms })
103
+ Response.ok(data: data, meta: Response.meta(started_at))
91
104
  rescue Coatepec::Error => e
92
105
  Response.error(e)
93
106
  end
94
107
  end
95
108
  end
96
109
 
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.
110
+ # The `rails_spec_flaky_check` MCP tool: runs targeted RSpec examples or
111
+ # Minitest tests multiple times with independently random seeds and
112
+ # reports which tests' pass/fail status was inconsistent across rounds.
113
+ # Separate tool from rails_spec_run for the same reason rails_spec_profile
114
+ # is -- see docs/superpowers/specs/2026-08-13-flaky-spec-detection-design.md.
102
115
  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(
116
+ INPUT_SCHEMA = {
108
117
  properties: {
109
118
  paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 },
110
119
  example: { type: %w[string null] },
@@ -113,7 +122,14 @@ module Coatepec
113
122
  },
114
123
  required: ["paths"],
115
124
  additionalProperties: false
116
- )
125
+ }.freeze
126
+
127
+ tool_name "rails_spec_flaky_check"
128
+ description "Run targeted RSpec examples or Minitest tests multiple times with random seeds to detect " \
129
+ "order-dependent or intermittent flakiness, reporting which tests' status was inconsistent " \
130
+ "across runs; the framework is chosen from the selector paths"
131
+ annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
132
+ input_schema(**INPUT_SCHEMA)
117
133
 
118
134
  class << self
119
135
  def call(paths:, server_context:, example: nil, timeout_seconds: 120, runs: 5)
@@ -121,17 +137,10 @@ module Coatepec
121
137
  data = server_context[:worker_manager].check_flaky(
122
138
  paths: paths, example: example, timeout_seconds: timeout_seconds, runs: runs
123
139
  )
124
- Response.ok(data: data, meta: meta_for(server_context, started_at))
140
+ Response.ok(data: data, meta: Response.meta(started_at))
125
141
  rescue Coatepec::Error => e
126
142
  Response.error(e)
127
143
  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
144
  end
136
145
  end
137
146
 
@@ -139,66 +148,71 @@ module Coatepec
139
148
  # Rails app's routes.
140
149
  class RoutesTool < ::MCP::Tool
141
150
  tool_name "rails_routes"
142
- description "Return a bounded, filterable list of the Rails app's routes"
151
+ description "Return a bounded, filterable list of the Rails app's routes, application routes only by " \
152
+ "default: mounted-engine routes (for example admin scaffolding) are withheld and the response's " \
153
+ "engines_excluded says how many routes matching the query the engines filter held back " \
154
+ "(application routes, under \"only\"); pass " \
155
+ "engines: \"include\" to list both or \"only\" for engine routes alone. Engine routes are " \
156
+ "expanded one level deep, carry the mount point in their path, and name their engine in the " \
157
+ "engine field (null for an application route; query also matches that field); returns columns " \
158
+ "(name, verb, path, controller, action, engine) and up to limit rows (default 100) in that " \
159
+ "order, paths without the (.:format) suffix Rails appends, with next_offset -- the offset to " \
160
+ "pass back for the next page, null on the last one" \
161
+ "; the engines default can be set project-wide in .coatepec.yml"
143
162
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
144
163
  input_schema(
145
164
  properties: {
146
165
  query: { type: %w[string null] },
147
166
  limit: { type: "integer", minimum: 1, maximum: 200 },
148
- offset: { type: "integer", minimum: 0 }
167
+ offset: { type: "integer", minimum: 0 },
168
+ engines: { type: "string", enum: %w[include exclude only] }
149
169
  },
150
170
  required: [],
151
171
  additionalProperties: false
152
172
  )
153
173
 
154
174
  class << self
155
- def call(server_context:, query: nil, limit: 50, offset: 0)
175
+ def call(server_context:, query: nil, limit: 100, offset: 0, engines: nil)
156
176
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
157
- data = server_context[:worker_manager].routes(query: query, limit: limit, offset: offset)
158
- Response.ok(data: data, meta: meta_for(server_context, started_at))
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)
179
+ Response.ok(data: data, meta: Response.meta(started_at))
159
180
  rescue Coatepec::Error => e
160
181
  Response.error(e)
161
182
  end
162
-
163
- private
164
-
165
- def meta_for(server_context, started_at)
166
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
167
- { project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
168
- end
169
183
  end
170
184
  end
171
185
 
172
- # The `rails_model` MCP tool: returns an ActiveRecord model's schema,
173
- # associations, validators, and enums.
186
+ # The `rails_model` MCP tool: returns an ActiveRecord model's table
187
+ # metadata and list counts, plus any lists named in fields.
174
188
  class ModelTool < ::MCP::Tool
175
189
  tool_name "rails_model"
176
- description "Return bounded ActiveRecord schema, associations, validators, and enums for a model, " \
177
- "without row data"
190
+ description "Return an ActiveRecord model's name, table, primary key, abstract_class and counts by default " \
191
+ "(the size of its columns, associations, validators and enums lists, each capped at 200), without " \
192
+ "row data; pass fields (any of columns, associations, validators, enums) to include those lists -- " \
193
+ "an omitted or empty fields returns no lists, only counts; validators are de-duplicated by class, " \
194
+ "attributes and options, so the list holds distinct validators and can be shorter than " \
195
+ "klass.validators; an array-valued validator option longer than 20 entries keeps its first 20 with " \
196
+ "<option>_count and <option>_truncated beside it"
178
197
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
179
198
  input_schema(
180
199
  properties: {
181
- name: { type: "string", pattern: '^[A-Z]\w*(?:::[A-Z]\w*)*$' }
200
+ name: { type: "string", pattern: '^[A-Z]\w*(?:::[A-Z]\w*)*$' },
201
+ fields: { type: "array", items: { type: "string", enum: %w[columns associations validators enums] },
202
+ uniqueItems: true }
182
203
  },
183
204
  required: ["name"],
184
205
  additionalProperties: false
185
206
  )
186
207
 
187
208
  class << self
188
- def call(name:, server_context:)
209
+ def call(name:, server_context:, fields: nil)
189
210
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
190
- data = server_context[:worker_manager].model(name: name)
191
- Response.ok(data: data, meta: meta_for(server_context, started_at))
211
+ data = server_context[:worker_manager].model(name: name, fields: fields)
212
+ Response.ok(data: data, meta: Response.meta(started_at))
192
213
  rescue Coatepec::Error => e
193
214
  Response.error(e)
194
215
  end
195
-
196
- private
197
-
198
- def meta_for(server_context, started_at)
199
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
200
- { project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
201
- end
202
216
  end
203
217
  end
204
218
 
@@ -221,17 +235,10 @@ module Coatepec
221
235
  def call(name:, server_context:)
222
236
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
237
  data = server_context[:worker_manager].controller(name: name)
224
- Response.ok(data: data, meta: meta_for(server_context, started_at))
238
+ Response.ok(data: data, meta: Response.meta(started_at))
225
239
  rescue Coatepec::Error => e
226
240
  Response.error(e)
227
241
  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
242
  end
236
243
  end
237
244
  end
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
@@ -20,8 +21,8 @@ module Coatepec
20
21
  ::MCP::Server.new(
21
22
  name: "coatepec",
22
23
  version: Coatepec::VERSION,
23
- tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, FlakyCheckTool, RoutesTool, ModelTool,
24
- ControllerTool],
24
+ tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, FlakyCheckTool,
25
+ RoutesTool, ModelTool, ControllerTool],
25
26
  server_context: { worker_manager: worker_manager, project_root: project.root }
26
27
  )
27
28
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Coatepec
4
4
  # A Rails application checkout rooted at an absolute path (must contain a
5
- # Gemfile); knows where its own and pack/engine/gem spec directories live.
5
+ # Gemfile); knows where its own and pack/engine/gem spec and test
6
+ # directories live.
6
7
  class Project
7
8
  attr_reader :root
8
9
 
@@ -23,6 +24,15 @@ module Coatepec
23
24
  ].select { |path| File.directory?(path) }
24
25
  end
25
26
 
27
+ def test_root_candidates
28
+ [
29
+ File.join(root, "test"),
30
+ *Dir.glob(File.join(root, "packs/*/test")),
31
+ *Dir.glob(File.join(root, "engines/*/test")),
32
+ *Dir.glob(File.join(root, "gems/*/test"))
33
+ ].select { |path| File.directory?(path) }
34
+ end
35
+
26
36
  def config
27
37
  @config ||= ProjectConfig.new(root)
28
38
  end
@@ -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)
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Spec
5
+ # Collapses failure blocks that repeat one error verbatim (a broken layout erroring every controller
6
+ # test, say) into the first block plus a roll-up line naming the other tests. Unrecognised text is kept.
7
+ module FailureCollapser
8
+ MINITEST_START = /\A(?:Error|Failure):\z/
9
+ MINITEST_ID = /\A(\S+#\S+?)(?: \[[^\]]*\])?:\z/
10
+ MINITEST_END = /\A\S*\brails test \S+:\d+\z/
11
+ RSPEC_START = /\A \d+\) (.+)\z/
12
+ RSPEC_END = /\A(?: \d+\) |Finished in |Failed examples:)/
13
+ # Backtrace frames and RSpec's "Failure/Error: <source line>" differ per test; the error text is what must match.
14
+ IGNORED = %r{\A\s+(?:# )?\S+:\d+(?::in .*)?\z|\A\s+Failure/Error: }
15
+
16
+ module_function
17
+
18
+ def call(text)
19
+ # read_bounded's tail byteslice can sever a multibyte character, and matching a regexp on that raises.
20
+ return text unless text.valid_encoding?
21
+
22
+ lines = text.lines
23
+ blocks = minitest_blocks(lines)
24
+ blocks = rspec_blocks(lines) if blocks.empty?
25
+ return text if blocks.size < 2
26
+
27
+ rewrite(lines, blocks)
28
+ end
29
+
30
+ # A block runs from the Error:/Failure: line through Rails' "bin/rails test path:LINE" rerun line.
31
+ def minitest_blocks(lines)
32
+ blocks = []
33
+ i = 0
34
+ while i < lines.size
35
+ id, stop = minitest_block_end(lines, i)
36
+ blocks << block(i, stop, id, lines[(i + 2)...stop]) if stop
37
+ i = (stop || i) + 1
38
+ end
39
+ blocks
40
+ end
41
+
42
+ def minitest_block_end(lines, index)
43
+ id = lines[index].chomp.match?(MINITEST_START) && lines[index + 1]&.chomp&.[](MINITEST_ID, 1)
44
+ return [nil, nil] unless id
45
+
46
+ [id, ((index + 2)...lines.size).find { |j| lines[j].chomp.match?(MINITEST_END) }]
47
+ end
48
+
49
+ # A block runs from " N) description" (after the Failures: heading only) to the next such line or the summary.
50
+ def rspec_blocks(lines)
51
+ rspec_starts(lines).map do |i|
52
+ last = rspec_block_end(lines, i)
53
+ block(i, last, lines[i].chomp[RSPEC_START, 1], lines[(i + 1)..last])
54
+ end
55
+ end
56
+
57
+ # Only the numbered entries under the Failures: heading, never RSpec's Pending: section.
58
+ def rspec_starts(lines)
59
+ failures_at = lines.index { |line| line.chomp == "Failures:" }
60
+ return [] unless failures_at
61
+
62
+ ((failures_at + 1)...lines.size).select { |i| lines[i].chomp.match?(RSPEC_START) }
63
+ end
64
+
65
+ # Stops before the next failure or the summary, then backs over the blank lines in between.
66
+ def rspec_block_end(lines, start)
67
+ stop = ((start + 1)...lines.size).find { |j| lines[j].chomp.match?(RSPEC_END) } || lines.size
68
+ stop -= 1 while stop > start + 1 && lines[stop - 1].strip.empty?
69
+ stop - 1
70
+ end
71
+
72
+ def block(first, last, id, body)
73
+ key = body.map(&:chomp).reject { |line| line.strip.empty? || line.match?(IGNORED) }
74
+ { first: first, last: last, id: id, key: key }
75
+ end
76
+
77
+ def rewrite(lines, blocks)
78
+ skipped = {}
79
+ notes = {}
80
+ blocks.group_by { |b| b[:key] }.each_value do |kept, *dropped|
81
+ next if dropped.empty?
82
+
83
+ dropped.each { |b| skip_block(lines, b, skipped) }
84
+ notes[kept[:last]] = note_for(dropped)
85
+ end
86
+ render(lines, skipped, notes)
87
+ end
88
+
89
+ def render(lines, skipped, notes)
90
+ lines.each_with_index.reject { |_, j| skipped[j] }.map { |line, j| attach_note(line, notes[j]) }.join
91
+ end
92
+
93
+ # The blank line that separates the dropped block from the next one goes with it.
94
+ def skip_block(lines, dropped, skipped)
95
+ (dropped[:first]..dropped[:last]).each { |j| skipped[j] = true }
96
+ skipped[dropped[:last] + 1] = true if lines[dropped[:last] + 1]&.strip&.empty?
97
+ end
98
+
99
+ def note_for(dropped)
100
+ noun = dropped.size == 1 ? "test" : "tests"
101
+ "#{dropped.size} more #{noun} failed with this same error: #{dropped.map { |b| b[:id] }.join(", ")}"
102
+ end
103
+
104
+ def attach_note(line, note)
105
+ return line unless note
106
+
107
+ "#{line.chomp}\n#{note}\n"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -44,9 +44,11 @@ module Coatepec
44
44
 
45
45
  def run_one_round(paths, example, timeout_seconds)
46
46
  seed = SecureRandom.random_number(65_536)
47
+ # Every round needs the full roster: a pass in one round is what makes a later failure flaky.
48
+ # stdout is never read from a round, so it is not captured into the result.
47
49
  result = @runner.run(
48
50
  paths: paths, example: example, seed: seed, fail_fast: false,
49
- timeout_seconds: timeout_seconds
51
+ timeout_seconds: timeout_seconds, include_passing: true, include_stdout: "never"
50
52
  )
51
53
  { seed: seed, status: result[:status], examples: result[:examples] }
52
54
  end
@@ -2,29 +2,22 @@
2
2
 
3
3
  module Coatepec
4
4
  module Spec
5
- # Runs RSpec in a `Process.fork`ed child (Linux only): cheap and reuses
5
+ # Runs the test framework in a `Process.fork`ed child (Linux only): cheap and reuses
6
6
  # the warm worker's loaded Rails boot, but isolated from the parent's
7
7
  # ActiveRecord connections and global state.
8
8
  class ForkStrategy < ProcessStrategy
9
9
  private
10
10
 
11
- def start(full_args, out_w, err_w)
11
+ def execution_mode = "fork"
12
+
13
+ def start(full_args, out_w, err_w, json_path)
12
14
  Process.fork do
13
15
  Process.setpgid(0, 0)
14
16
  redirect_output(out_w, err_w)
15
17
  # Forked children must not share the parent's live DB sockets.
16
18
  ActiveRecord::Base.connection_handler.clear_all_connections! if defined?(ActiveRecord::Base)
17
- # RSpec freezes its own "load started at" timestamp once, at the moment
18
- # rspec/core.rb is first required -- in this architecture, that's when
19
- # the long-lived warm worker booted, not when THIS run started. Every
20
- # forked child inherits that frozen timestamp via copy-on-write, so
21
- # RSpec's own "(files took N seconds to load)" reporting would
22
- # otherwise measure "time since the worker booted" and grow across
23
- # every run for as long as the worker stays warm. Reset it fresh
24
- # before each run.
25
- RSpec.configuration.start_time = RSpec::Core::Time.now
26
19
 
27
- status = RSpec::Core::Runner.run(full_args, $stderr, $stdout)
20
+ status = @adapter.run_in_process(full_args, json_path)
28
21
  $stdout.flush
29
22
  $stderr.flush
30
23
  Kernel.exit!(status)
@@ -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
@@ -36,13 +37,14 @@ module Coatepec
36
37
  # along with a whole second result inside MCP::Response's 1 MiB cap.
37
38
  MAX_CRASH_STDERR_BYTES = 4 * 1024
38
39
 
39
- def initialize(project_root, project: nil, rails_runtime: nil)
40
+ def initialize(project_root, adapter: nil, project: nil, rails_runtime: nil)
40
41
  super
41
- @spawn_strategy = SpawnStrategy.new(project_root)
42
+ @spawn_strategy = SpawnStrategy.new(project_root, adapter: @adapter)
42
43
  end
43
44
 
44
- def run(args, timeout_seconds)
45
- return fallback_result(args, timeout_seconds, "spawn_fallback") unless guard_passes?
45
+ def run(args, timeout_seconds, include_passing: false, include_stdout: "failures")
46
+ result_options = { include_passing: include_passing, include_stdout: include_stdout }
47
+ return fallback_result(args, timeout_seconds, "spawn_fallback", result_options) unless guard_passes?
46
48
 
47
49
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
50
  begin
@@ -51,22 +53,22 @@ module Coatepec
51
53
  # Process.fork itself failed (Errno::EAGAIN/ENOMEM under
52
54
  # process-table pressure), so no child was ever produced -- from the
53
55
  # caller's side that is indistinguishable from a failed guard, hence
54
- # the same mode. Opting into macos_fork must never surface an error
55
- # that plain SpawnStrategy wouldn't have.
56
- return fallback_result(args, timeout_seconds, "spawn_fallback")
56
+ # the same mode. Forking on macOS must never surface an error that
57
+ # plain SpawnStrategy wouldn't have.
58
+ return fallback_result(args, timeout_seconds, "spawn_fallback", result_options)
57
59
  end
58
- return result.merge(execution_mode: "fork") unless crashed?(result)
60
+ return result unless crashed?(result)
59
61
 
60
- retry_after_crash(args, timeout_seconds, started_at, result)
62
+ retry_after_crash(args, timeout_seconds, started_at, result, result_options)
61
63
  end
62
64
 
63
65
  private
64
66
 
65
- def retry_after_crash(args, timeout_seconds, started_at, crashed)
67
+ def retry_after_crash(args, timeout_seconds, started_at, crashed, result_options)
66
68
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
67
69
  remaining = [timeout_seconds - elapsed, MIN_RETRY_TIMEOUT_SECONDS].max
68
70
 
69
- fallback_result(args, remaining, "spawn_after_crash")
71
+ fallback_result(args, remaining, "spawn_after_crash", result_options)
70
72
  .merge(crashed_fork_stderr: crash_diagnostics(crashed))
71
73
  end
72
74
 
@@ -79,8 +81,8 @@ module Coatepec
79
81
  text.byteslice(-MAX_CRASH_STDERR_BYTES, MAX_CRASH_STDERR_BYTES)
80
82
  end
81
83
 
82
- def fallback_result(args, timeout_seconds, mode)
83
- @spawn_strategy.run(args, timeout_seconds).merge(execution_mode: mode)
84
+ def fallback_result(args, timeout_seconds, mode, result_options)
85
+ @spawn_strategy.run(args, timeout_seconds, **result_options).merge(execution_mode: mode)
84
86
  end
85
87
 
86
88
  def guard_passes?