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.
- checksums.yaml +4 -4
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +140 -0
- data/README.md +153 -50
- data/ROADMAP.md +31 -64
- 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/defaults.rb +25 -0
- data/lib/coatepec/mcp/response.rb +16 -9
- data/lib/coatepec/mcp/tools.rb +100 -93
- data/lib/coatepec/mcp.rb +3 -2
- data/lib/coatepec/project.rb +11 -1
- data/lib/coatepec/project_config.rb +40 -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 +5 -12
- data/lib/coatepec/spec/guarded_fork_strategy.rb +20 -18
- data/lib/coatepec/spec/path_policy.rb +45 -12
- data/lib/coatepec/spec/process_strategy.rb +28 -17
- data/lib/coatepec/spec/result.rb +61 -19
- data/lib/coatepec/spec/rspec_adapter.rb +58 -0
- data/lib/coatepec/spec/runner.rb +33 -27
- data/lib/coatepec/spec/spawn_strategy.rb +9 -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/rails_runtime.rb +10 -1
- data/lib/coatepec/worker/server.rb +9 -2
- data/lib/coatepec/worker_manager.rb +8 -6
- data/lib/coatepec.rb +5 -0
- metadata +17 -5
data/lib/coatepec/mcp/tools.rb
CHANGED
|
@@ -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
|
|
6
|
-
# the warm test worker and
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
48
|
-
# versions, PID, boot_id,
|
|
49
|
-
#
|
|
50
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
#
|
|
100
|
-
# tool from rails_spec_run for the same reason rails_spec_profile
|
|
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
|
-
|
|
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:
|
|
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:
|
|
175
|
+
def call(server_context:, query: nil, limit: 100, offset: 0, engines: nil)
|
|
156
176
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
173
|
-
#
|
|
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
|
|
177
|
-
"without
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
data/lib/coatepec/project.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# crashes. See
|
|
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
|
-
|
|
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.
|
|
55
|
-
#
|
|
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
|
|
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?
|