coatepec 0.5.2 → 0.6.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/CHANGELOG.md +11 -0
- data/README.md +25 -0
- data/lib/coatepec/mcp/tools.rb +41 -0
- data/lib/coatepec/mcp.rb +3 -3
- data/lib/coatepec/spec/flaky_checker.rb +87 -0
- data/lib/coatepec/version.rb +1 -1
- data/lib/coatepec/worker/server.rb +5 -0
- data/lib/coatepec/worker_manager.rb +12 -0
- data/lib/coatepec.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ddcc044bcc53590ba19d988b0db8ea0aea4efd7db102bd6718196e61ab9ab820
|
|
4
|
+
data.tar.gz: 1b8b779b5bb59068a7fdd5fc90e7d44b900d50f1918e1d68877a9ccad24bf1e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56fce0755730ab151c81fb43f88a28d6e081b242c3e9bb31fac71578c290e864efbc5e5313702a4c015b95a661cdd601f1701979c98dbbdae1a0cfa06e0be2fa
|
|
7
|
+
data.tar.gz: 4101b3595de972478ac414f3c019b413ec991cfe1d8ea0fbb251010fad6d699eaeb849818a1c4dbcfe813b47b41af220b68a7f971efe287dbbb8c598d8ff9763
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Add `rails_spec_flaky_check`: runs a spec selection multiple times with
|
|
6
|
+
independently random seeds (RSpec's `--seed` is equivalent to `--order
|
|
7
|
+
rand:SEED`) and reports which examples' pass/fail status was
|
|
8
|
+
inconsistent across runs -- separately from examples that failed every
|
|
9
|
+
run (`consistently_failing`, not flaky) and examples that passed every
|
|
10
|
+
run (omitted). Each round's seed is included in the response so a
|
|
11
|
+
specific divergence can be reproduced with a plain `rails_spec_run`
|
|
12
|
+
call. `rails_spec_run` itself is unchanged by this release.
|
|
13
|
+
|
|
3
14
|
## 0.5.2
|
|
4
15
|
|
|
5
16
|
- Fix `WorkerManager` getting permanently stuck treating a dead worker as
|
data/README.md
CHANGED
|
@@ -130,6 +130,7 @@ a no-op.
|
|
|
130
130
|
| `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot |
|
|
131
131
|
| `rails_routes` | `query?`, `limit?` (1..200, default 50), `offset?` | Case-insensitive filter across name/verb/path/controller/action |
|
|
132
132
|
| `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`) | ActiveRecord models only; columns, associations, validators, enums -- no row data |
|
|
133
|
+
| `rails_spec_flaky_check` | `paths`, `example?`, `timeout_seconds?` (per round, 1..900), `runs?` (2..20, default 5) | Runs the selection `runs` times with a fresh random seed each round; reports examples whose status was inconsistent across runs |
|
|
133
134
|
|
|
134
135
|
### Example queries
|
|
135
136
|
|
|
@@ -156,6 +157,30 @@ a no-op.
|
|
|
156
157
|
rather than introspecting it (a nonexistent constant raises `model_not_found`
|
|
157
158
|
instead) -- the tool only ever reflects on `ActiveRecord::Base` descendants.
|
|
158
159
|
|
|
160
|
+
`rails_spec_flaky_check`:
|
|
161
|
+
|
|
162
|
+
- "Is this spec flaky?" -- `rails_spec_flaky_check(paths: ["spec/models/widget_spec.rb"])`
|
|
163
|
+
runs it 5 times (default), each with an independently random seed
|
|
164
|
+
(equivalent to RSpec's `--order rand:SEED`), and reports any example
|
|
165
|
+
whose pass/fail status wasn't the same every time under `flaky_examples`
|
|
166
|
+
-- distinct from `consistently_failing` (fails every run: broken, not
|
|
167
|
+
flaky) and examples that passed every run (omitted -- nothing to report).
|
|
168
|
+
- Each round's seed is included in the response (`rounds[].seed`), so a
|
|
169
|
+
specific divergence can be reproduced afterward with a plain
|
|
170
|
+
`rails_spec_run(seed: <that seed>)`.
|
|
171
|
+
- `timeout_seconds` is a **per-round** budget, not a total; `runs *
|
|
172
|
+
timeout_seconds` is capped at 1800s combined (`flaky_check_budget_exceeded`
|
|
173
|
+
if exceeded) since this tool can run for a while.
|
|
174
|
+
- Selections over 500 examples are capped per round (the same limit
|
|
175
|
+
`rails_spec_run` already has), and each round samples a different subset,
|
|
176
|
+
since execution order varies by design -- for suites this large, narrow
|
|
177
|
+
`paths`/`example` rather than passing a very broad directory selection.
|
|
178
|
+
- `statuses[]` only aligns positionally with `rounds[]` when no round
|
|
179
|
+
crashed -- a round whose process itself failed contributes no entry to
|
|
180
|
+
`statuses[]` (though it still appears in `rounds[]`), so treat positional
|
|
181
|
+
correspondence as best-effort, not guaranteed, when a round's `status` in
|
|
182
|
+
`rounds[]` looks like an outright crash rather than a normal pass/fail.
|
|
183
|
+
|
|
159
184
|
The warm test worker forces Rails' reload-checking on for its own boot,
|
|
160
185
|
regardless of the target app's own `test.rb` setting (which disables it by
|
|
161
186
|
default) -- so editing a model file takes effect on the next tool call
|
data/lib/coatepec/mcp/tools.rb
CHANGED
|
@@ -94,6 +94,47 @@ module Coatepec
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# The `rails_spec_flaky_check` MCP tool: runs targeted RSpec examples
|
|
98
|
+
# multiple times with independently random seeds and reports which
|
|
99
|
+
# examples' pass/fail status was inconsistent across rounds. Separate
|
|
100
|
+
# tool from rails_spec_run for the same reason rails_spec_profile is --
|
|
101
|
+
# see docs/superpowers/specs/2026-08-13-flaky-spec-detection-design.md.
|
|
102
|
+
class FlakyCheckTool < ::MCP::Tool
|
|
103
|
+
tool_name "rails_spec_flaky_check"
|
|
104
|
+
description "Run targeted RSpec examples multiple times with random seeds to detect order-dependent or " \
|
|
105
|
+
"intermittent flakiness, reporting which examples' status was inconsistent across runs"
|
|
106
|
+
annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
|
|
107
|
+
input_schema(
|
|
108
|
+
properties: {
|
|
109
|
+
paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 },
|
|
110
|
+
example: { type: %w[string null] },
|
|
111
|
+
timeout_seconds: { type: "integer", minimum: 1, maximum: 900 },
|
|
112
|
+
runs: { type: "integer", minimum: 2, maximum: 20 }
|
|
113
|
+
},
|
|
114
|
+
required: ["paths"],
|
|
115
|
+
additionalProperties: false
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
def call(paths:, server_context:, example: nil, timeout_seconds: 120, runs: 5)
|
|
120
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
121
|
+
data = server_context[:worker_manager].check_flaky(
|
|
122
|
+
paths: paths, example: example, timeout_seconds: timeout_seconds, runs: runs
|
|
123
|
+
)
|
|
124
|
+
Response.ok(data: data, meta: meta_for(server_context, started_at))
|
|
125
|
+
rescue Coatepec::Error => e
|
|
126
|
+
Response.error(e)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def meta_for(server_context, started_at)
|
|
132
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
133
|
+
{ project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
97
138
|
# The `rails_routes` MCP tool: lists/filters/paginates the target
|
|
98
139
|
# Rails app's routes.
|
|
99
140
|
class RoutesTool < ::MCP::Tool
|
data/lib/coatepec/mcp.rb
CHANGED
|
@@ -13,14 +13,14 @@ require_relative "mcp/tools"
|
|
|
13
13
|
|
|
14
14
|
module Coatepec
|
|
15
15
|
# Wires the `rails_spec_run`, `rails_runtime_status`, `rails_runtime_restart`,
|
|
16
|
-
# `rails_routes`, and `rails_model` tools into an
|
|
17
|
-
# backed by the given project's worker manager.
|
|
16
|
+
# `rails_spec_flaky_check`, `rails_routes`, and `rails_model` tools into an
|
|
17
|
+
# `::MCP::Server` instance backed by the given project's worker manager.
|
|
18
18
|
module MCP
|
|
19
19
|
def self.build_server(project:, worker_manager:)
|
|
20
20
|
::MCP::Server.new(
|
|
21
21
|
name: "coatepec",
|
|
22
22
|
version: Coatepec::VERSION,
|
|
23
|
-
tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, RoutesTool, ModelTool],
|
|
23
|
+
tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, FlakyCheckTool, RoutesTool, ModelTool],
|
|
24
24
|
server_context: { worker_manager: worker_manager, project_root: project.root }
|
|
25
25
|
)
|
|
26
26
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module Spec
|
|
7
|
+
# Runs a spec selection multiple times, each round with an
|
|
8
|
+
# independently random seed (RSpec's --seed N is equivalent to --order
|
|
9
|
+
# rand:N, confirmed against rspec-core's own option_parser.rb), and
|
|
10
|
+
# groups per-example pass/fail status across rounds by the example's
|
|
11
|
+
# own id -- stable regardless of execution order, since it's derived
|
|
12
|
+
# from declaration position/nesting, not from --order. Composes Runner
|
|
13
|
+
# unchanged, exactly like FactoryProfRunner: every round is a plain,
|
|
14
|
+
# ordinary Runner#run call with a different seed, no strategy override
|
|
15
|
+
# needed (unlike FactoryProfRunner, this feature has no load-time
|
|
16
|
+
# activation trick to fight -- see
|
|
17
|
+
# docs/superpowers/specs/2026-08-13-flaky-spec-detection-design.md).
|
|
18
|
+
class FlakyChecker
|
|
19
|
+
DEFAULT_RUNS = 5
|
|
20
|
+
MAX_TOTAL_SECONDS = 1800 # timeout_seconds * runs must not exceed this
|
|
21
|
+
MAX_ITEMS = 200 # same bound rails_model/FactoryProfRunner use elsewhere
|
|
22
|
+
|
|
23
|
+
def initialize(project_root, rails_runtime: nil)
|
|
24
|
+
@runner = Runner.new(project_root, rails_runtime: rails_runtime)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(paths:, example: nil, timeout_seconds: Runner::DEFAULT_TIMEOUT, runs: DEFAULT_RUNS)
|
|
28
|
+
validate_budget!(timeout_seconds, runs)
|
|
29
|
+
rounds = Array.new(runs) { run_one_round(paths, example, timeout_seconds) }
|
|
30
|
+
build_report(rounds)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_budget!(timeout_seconds, runs)
|
|
36
|
+
return if timeout_seconds * runs <= MAX_TOTAL_SECONDS
|
|
37
|
+
|
|
38
|
+
raise Coatepec::Error.new(
|
|
39
|
+
:flaky_check_budget_exceeded,
|
|
40
|
+
"timeout_seconds (#{timeout_seconds}) * runs (#{runs}) exceeds the " \
|
|
41
|
+
"#{MAX_TOTAL_SECONDS}s combined budget -- lower one or both"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run_one_round(paths, example, timeout_seconds)
|
|
46
|
+
seed = SecureRandom.random_number(65_536)
|
|
47
|
+
result = @runner.run(
|
|
48
|
+
paths: paths, example: example, seed: seed, fail_fast: false,
|
|
49
|
+
timeout_seconds: timeout_seconds
|
|
50
|
+
)
|
|
51
|
+
{ seed: seed, status: result[:status], examples: result[:examples] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_report(rounds)
|
|
55
|
+
by_id = group_examples_by_id(rounds)
|
|
56
|
+
{
|
|
57
|
+
runs: rounds.size,
|
|
58
|
+
rounds: rounds.map { |r| { seed: r[:seed], status: r[:status] } },
|
|
59
|
+
flaky_examples: classify(by_id) { |statuses| statuses.uniq.size > 1 },
|
|
60
|
+
consistently_failing: classify(by_id) { |statuses| statuses.uniq == ["failed"] }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rounds whose RSpec process itself crashed/timed out (not a specific
|
|
65
|
+
# example failing -- the whole run killed before its JSON summary was
|
|
66
|
+
# ever written) contribute no per-example data here. Excluded rather
|
|
67
|
+
# than counted as "every example failed", which would misrepresent a
|
|
68
|
+
# dead process as evidence against examples that never actually ran;
|
|
69
|
+
# the round itself is still visible in the `rounds:` summary.
|
|
70
|
+
def group_examples_by_id(rounds)
|
|
71
|
+
rounds.each_with_object({}) do |round, acc|
|
|
72
|
+
round[:examples].each { |ex| (acc[ex[:id]] ||= { meta: ex, statuses: [] })[:statuses] << ex[:status] }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def classify(by_id)
|
|
77
|
+
by_id.values.select { |v| yield(v[:statuses]) }.first(MAX_ITEMS).map do |v|
|
|
78
|
+
v[:meta].slice(:id, :description, :file_path, :line_number).merge(
|
|
79
|
+
statuses: v[:statuses],
|
|
80
|
+
pass_count: v[:statuses].count("passed"),
|
|
81
|
+
failure_count: v[:statuses].count("failed")
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/coatepec/version.rb
CHANGED
|
@@ -42,6 +42,7 @@ module Coatepec
|
|
|
42
42
|
case command
|
|
43
43
|
when "status" then @runtime.status
|
|
44
44
|
when "spec_run" then handle_spec_run(args)
|
|
45
|
+
when "flaky_check" then handle_flaky_check(args)
|
|
45
46
|
when "routes" then handle_routes(args)
|
|
46
47
|
when "model" then handle_model(args)
|
|
47
48
|
else
|
|
@@ -53,6 +54,10 @@ module Coatepec
|
|
|
53
54
|
Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
|
|
54
55
|
end
|
|
55
56
|
|
|
57
|
+
def handle_flaky_check(args)
|
|
58
|
+
Spec::FlakyChecker.new(@project_root, rails_runtime: @runtime).call(**args.transform_keys(&:to_sym))
|
|
59
|
+
end
|
|
60
|
+
|
|
56
61
|
def handle_routes(args)
|
|
57
62
|
Introspection::Routes.new(**args.transform_keys(&:to_sym)).call
|
|
58
63
|
end
|
|
@@ -28,6 +28,18 @@ module Coatepec
|
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
def check_flaky(paths:, example:, timeout_seconds:, runs:)
|
|
32
|
+
dispatch(
|
|
33
|
+
"flaky_check",
|
|
34
|
+
{ paths: paths, example: example, timeout_seconds: timeout_seconds, runs: runs },
|
|
35
|
+
# Each round costs timeout_seconds *plus* real per-round overhead (process
|
|
36
|
+
# termination/spawn/result-parsing -- roughly 0.6s+ per round from
|
|
37
|
+
# ProcessStrategy#terminate's own escalation sleeps alone), so the flat
|
|
38
|
+
# base slack below is scaled by an extra ~2s per round on top of it.
|
|
39
|
+
timeout: (timeout_seconds * runs) + (2 * runs) + 10
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
31
43
|
def routes(query: nil, limit: 50, offset: 0)
|
|
32
44
|
dispatch("routes", { query: query, limit: limit, offset: offset }, timeout: 30)
|
|
33
45
|
end
|
data/lib/coatepec.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative "coatepec/spec/fork_strategy"
|
|
|
18
18
|
require_relative "coatepec/spec/spawn_strategy"
|
|
19
19
|
require_relative "coatepec/spec/guarded_fork_strategy"
|
|
20
20
|
require_relative "coatepec/spec/runner"
|
|
21
|
+
require_relative "coatepec/spec/flaky_checker"
|
|
21
22
|
require_relative "coatepec/worker/server"
|
|
22
23
|
require_relative "coatepec/worker_manager"
|
|
23
24
|
|
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.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Enrique Mogollan
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-08-
|
|
10
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- lib/coatepec/project.rb
|
|
75
75
|
- lib/coatepec/project_config.rb
|
|
76
76
|
- lib/coatepec/protocol.rb
|
|
77
|
+
- lib/coatepec/spec/flaky_checker.rb
|
|
77
78
|
- lib/coatepec/spec/fork_strategy.rb
|
|
78
79
|
- lib/coatepec/spec/guarded_fork_strategy.rb
|
|
79
80
|
- lib/coatepec/spec/path_policy.rb
|