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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be24375e5f36c63e0511dc268bacecd57a54a33a94f5d44540925927c49f6655
4
- data.tar.gz: 0e80f68351e4e814e9e4486f7e02d8d3f70512edaa7e9806c6baa3091111e779
3
+ metadata.gz: ddcc044bcc53590ba19d988b0db8ea0aea4efd7db102bd6718196e61ab9ab820
4
+ data.tar.gz: 1b8b779b5bb59068a7fdd5fc90e7d44b900d50f1918e1d68877a9ccad24bf1e7
5
5
  SHA512:
6
- metadata.gz: 297b7eb82d12ece3991893ed1052650648d37fb86b438356257e67e90f69260297b5c3f4b390a2ce25860e2e06a8487dc1597bb39219c3f13ac550901cc92e94
7
- data.tar.gz: dbe1f843c872f42c20d7df9ee4226af83200b9c9457bb2a9b3aba185d22b7c5c8878fa3237a57dc4dfb4526c499e4c75cb1cb5c3135262aa24dd71fc4ea8805b
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
@@ -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 `::MCP::Server` instance
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coatepec
4
- VERSION = "0.5.2"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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.5.2
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 00:00:00.000000000 Z
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