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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "minitest"
|
|
5
|
+
|
|
6
|
+
module Coatepec
|
|
7
|
+
module TestUnit
|
|
8
|
+
# A Minitest reporter that writes the run's per-test results to a JSON
|
|
9
|
+
# file in a superset of the shape RSpec's own `--format json` produces, as far
|
|
10
|
+
# as Coatepec::Spec::Result reads it -- so Result and FlakyChecker need
|
|
11
|
+
# no Minitest-specific code at all. Registered in the child by
|
|
12
|
+
# TestUnit::Adapter#run_in_process; never loaded in the warm worker.
|
|
13
|
+
class JsonReporter < ::Minitest::AbstractReporter
|
|
14
|
+
def initialize(json_path, project_root)
|
|
15
|
+
super()
|
|
16
|
+
@json_path = json_path
|
|
17
|
+
@project_root = File.join(File.expand_path(project_root), "")
|
|
18
|
+
@examples = []
|
|
19
|
+
@assertions = 0
|
|
20
|
+
@errors = 0
|
|
21
|
+
@started_at = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A run begins: forget anything recorded before, so a reporter that
|
|
25
|
+
# outlives one Minitest.run never carries results into the next.
|
|
26
|
+
def start
|
|
27
|
+
@examples = []
|
|
28
|
+
@assertions = 0
|
|
29
|
+
@errors = 0
|
|
30
|
+
@started_at = ::Minitest.clock_time
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Minitest counts an assertion as it starts, so a failed assert still counts one; an error is a raise.
|
|
34
|
+
def record(result)
|
|
35
|
+
@examples << example_for(result)
|
|
36
|
+
@assertions += result.assertions
|
|
37
|
+
@errors += 1 if result.error?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Written to a sibling and renamed so a child killed mid-write leaves
|
|
41
|
+
# either a complete document or nothing; Result.read_summary already
|
|
42
|
+
# treats a missing/empty file as "no summary".
|
|
43
|
+
def report
|
|
44
|
+
tmp_path = "#{@json_path}.tmp"
|
|
45
|
+
File.write(tmp_path, JSON.generate(document))
|
|
46
|
+
File.rename(tmp_path, @json_path)
|
|
47
|
+
ensure
|
|
48
|
+
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# This reporter observes; it must never flip the run's exit status.
|
|
52
|
+
def passed?
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def document
|
|
59
|
+
{ summary: summary_counts, examples: @examples }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# assertion_count and error_count are running totals; the rest are derived from the recorded examples.
|
|
63
|
+
def summary_counts
|
|
64
|
+
{
|
|
65
|
+
example_count: @examples.size,
|
|
66
|
+
failure_count: @examples.count { |e| e[:status] == "failed" },
|
|
67
|
+
error_count: @errors,
|
|
68
|
+
pending_count: @examples.count { |e| e[:status] == "pending" },
|
|
69
|
+
assertion_count: @assertions,
|
|
70
|
+
duration: @started_at ? ::Minitest.clock_time - @started_at : 0.0
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def example_for(result)
|
|
75
|
+
id = "#{result.klass}##{result.name}"
|
|
76
|
+
file, line = result.source_location
|
|
77
|
+
{
|
|
78
|
+
id: id,
|
|
79
|
+
full_description: id,
|
|
80
|
+
status: status_for(result),
|
|
81
|
+
file_path: relative_path(file),
|
|
82
|
+
line_number: line
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# RSpec's vocabulary: a skip is "pending" (so FlakyChecker's passed/failed
|
|
87
|
+
# counts stay meaningful), an error is "failed" -- summary.error_count keeps the distinction.
|
|
88
|
+
def status_for(result)
|
|
89
|
+
return "pending" if result.skipped?
|
|
90
|
+
return "passed" if result.passed?
|
|
91
|
+
|
|
92
|
+
"failed"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Never emit the absolute path: same leak rails_controller avoids with
|
|
96
|
+
# Proc#to_s. Matches RSpec's "./spec/..." form for the same file.
|
|
97
|
+
def relative_path(file)
|
|
98
|
+
return nil if file.nil? || file == "unknown"
|
|
99
|
+
|
|
100
|
+
expanded = File.expand_path(file)
|
|
101
|
+
return "./#{expanded.delete_prefix(@project_root)}" if expanded.start_with?(@project_root)
|
|
102
|
+
|
|
103
|
+
File.basename(expanded)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module TestUnit
|
|
5
|
+
# Makes `file:LINE` selectors work regardless of the target app's Rails/
|
|
6
|
+
# Minitest pairing. Rails installs its own Rails::LineFiltering only via
|
|
7
|
+
# Rails::TestUnitRailtie, which an app generated with --skip-test never
|
|
8
|
+
# loads; and Rails 7.1's version overrides `run`, a method Minitest 6 no
|
|
9
|
+
# longer calls (it dispatches through `run_suite` and reads
|
|
10
|
+
# options[:include], not options[:filter]). So the child always extends
|
|
11
|
+
# ActiveSupport::TestCase with the one method the *running* Minitest
|
|
12
|
+
# actually calls, composing Rails::TestUnit::Runner's recorded line
|
|
13
|
+
# filters through Rails' public compose_filter. Stacking on top of
|
|
14
|
+
# Rails' own module is idempotent: CompositeFilter#derive_named_filter
|
|
15
|
+
# unwraps an existing composite via #named_filter.
|
|
16
|
+
#
|
|
17
|
+
# Exactly one method per module, never both: on Minitest 6 a class-level
|
|
18
|
+
# `run` override intercepts an unrelated 3-argument call and raises.
|
|
19
|
+
module LineFiltering
|
|
20
|
+
MT6 = Module.new do
|
|
21
|
+
def run_suite(reporter, options = {})
|
|
22
|
+
options = options.merge(include: ::Rails::TestUnit::Runner.compose_filter(self, options[:include]))
|
|
23
|
+
super(reporter, options)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
MT5 = Module.new do
|
|
28
|
+
def run(reporter, options = {})
|
|
29
|
+
options = options.merge(filter: ::Rails::TestUnit::Runner.compose_filter(self, options[:filter]))
|
|
30
|
+
super(reporter, options)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.module_for(minitest_version)
|
|
35
|
+
minitest_version.start_with?("6") ? MT6 : MT5
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Ruby ignores a second `extend` of a module already in the singleton
|
|
39
|
+
# ancestors, so calling this more than once per process is harmless.
|
|
40
|
+
def self.install!
|
|
41
|
+
require "rails/test_unit/runner"
|
|
42
|
+
::ActiveSupport::TestCase.extend(module_for(::Minitest::VERSION))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/coatepec/version.rb
CHANGED
|
@@ -8,17 +8,26 @@ module Coatepec
|
|
|
8
8
|
# per worker process and reports its identity (pid, boot_id, versions,
|
|
9
9
|
# lifecycle state) for rails_runtime_status.
|
|
10
10
|
class RailsRuntime
|
|
11
|
-
attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count
|
|
11
|
+
attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count,
|
|
12
|
+
:fallback_count
|
|
13
|
+
|
|
14
|
+
FALLBACK_MODES = %w[spawn_fallback spawn_after_crash].freeze
|
|
12
15
|
|
|
13
16
|
def initialize(project_root)
|
|
14
17
|
@project_root = project_root
|
|
15
18
|
@booted = false
|
|
19
|
+
@fallback_count = 0
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def booted?
|
|
19
23
|
@booted
|
|
20
24
|
end
|
|
21
25
|
|
|
26
|
+
# A guarded fork that declined or crashed ran via spawn; rails_runtime_status reports how often.
|
|
27
|
+
def record_execution_mode(mode)
|
|
28
|
+
@fallback_count += 1 if FALLBACK_MODES.include?(mode)
|
|
29
|
+
end
|
|
30
|
+
|
|
22
31
|
def boot!
|
|
23
32
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
33
|
ENV["RAILS_ENV"] = "test"
|
|
@@ -40,7 +40,7 @@ module Coatepec
|
|
|
40
40
|
|
|
41
41
|
def execute_command(command, args)
|
|
42
42
|
case command
|
|
43
|
-
when "status" then
|
|
43
|
+
when "status" then handle_status
|
|
44
44
|
when "spec_run" then handle_spec_run(args)
|
|
45
45
|
when "flaky_check" then handle_flaky_check(args)
|
|
46
46
|
when "routes" then handle_routes(args)
|
|
@@ -51,6 +51,12 @@ module Coatepec
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# spec_strategy and fallbacks are the worker's to report: it owns the platform check and the counter.
|
|
55
|
+
def handle_status
|
|
56
|
+
name = Spec::Runner.new(@project_root, rails_runtime: @runtime).strategy_name
|
|
57
|
+
@runtime.status.merge(spec_strategy: name, fallbacks: name == "guarded_fork" ? @runtime.fallback_count : nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
54
60
|
def handle_spec_run(args)
|
|
55
61
|
Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
|
|
56
62
|
end
|
|
@@ -64,7 +70,8 @@ module Coatepec
|
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
def handle_model(args)
|
|
67
|
-
|
|
73
|
+
a = args.transform_keys(&:to_sym)
|
|
74
|
+
Introspection::Model.new(a[:name], fields: a[:fields]).call
|
|
68
75
|
end
|
|
69
76
|
|
|
70
77
|
def handle_controller(args)
|
|
@@ -20,10 +20,12 @@ module Coatepec
|
|
|
20
20
|
dispatch("status", {}, timeout: 30)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def run_spec(paths:, example:, seed:, fail_fast:, timeout_seconds:
|
|
23
|
+
def run_spec(paths:, example:, seed:, fail_fast:, timeout_seconds:, include_passing: false,
|
|
24
|
+
include_stdout: "failures")
|
|
24
25
|
dispatch(
|
|
25
26
|
"spec_run",
|
|
26
|
-
{ paths: paths, example: example, seed: seed, fail_fast: fail_fast, timeout_seconds: timeout_seconds
|
|
27
|
+
{ paths: paths, example: example, seed: seed, fail_fast: fail_fast, timeout_seconds: timeout_seconds,
|
|
28
|
+
include_passing: include_passing, include_stdout: include_stdout },
|
|
27
29
|
timeout: timeout_seconds + 10
|
|
28
30
|
)
|
|
29
31
|
end
|
|
@@ -40,12 +42,12 @@ module Coatepec
|
|
|
40
42
|
)
|
|
41
43
|
end
|
|
42
44
|
|
|
43
|
-
def routes(query: nil, limit:
|
|
44
|
-
dispatch("routes", { query: query, limit: limit, offset: offset }, timeout: 30)
|
|
45
|
+
def routes(query: nil, limit: 100, offset: 0, engines: Introspection::Routes::DEFAULT_ENGINES)
|
|
46
|
+
dispatch("routes", { query: query, limit: limit, offset: offset, engines: engines }, timeout: 30)
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
def model(name:)
|
|
48
|
-
dispatch("model", { name: name }, timeout: 30)
|
|
49
|
+
def model(name:, fields: nil)
|
|
50
|
+
dispatch("model", { name: name, fields: fields }, timeout: 30)
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def controller(name:)
|
data/lib/coatepec.rb
CHANGED
|
@@ -11,9 +11,14 @@ require_relative "coatepec/worker/client"
|
|
|
11
11
|
require_relative "coatepec/worker/rails_runtime"
|
|
12
12
|
require_relative "coatepec/introspection/routes"
|
|
13
13
|
require_relative "coatepec/introspection/safe_options"
|
|
14
|
+
require_relative "coatepec/introspection/bounded_options"
|
|
15
|
+
require_relative "coatepec/introspection/model_resolver"
|
|
14
16
|
require_relative "coatepec/introspection/model"
|
|
15
17
|
require_relative "coatepec/introspection/controller"
|
|
18
|
+
require_relative "coatepec/spec/failure_collapser"
|
|
16
19
|
require_relative "coatepec/spec/result"
|
|
20
|
+
require_relative "coatepec/spec/rspec_adapter"
|
|
21
|
+
require_relative "coatepec/test_unit/adapter"
|
|
17
22
|
require_relative "coatepec/spec/process_strategy"
|
|
18
23
|
require_relative "coatepec/spec/fork_strategy"
|
|
19
24
|
require_relative "coatepec/spec/spawn_strategy"
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Enrique Mogollan
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -44,8 +44,9 @@ dependencies:
|
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
45
|
version: '1.0'
|
|
46
46
|
description: Coatepec is a local stdio MCP sidecar that keeps an isolated Rails test
|
|
47
|
-
worker warm so coding agents can run targeted RSpec examples quickly,
|
|
48
|
-
|
|
47
|
+
worker warm so coding agents can run targeted RSpec examples quickly, check specs
|
|
48
|
+
for flakiness, and read routes, models, and controllers through structured read-only
|
|
49
|
+
Rails APIs -- without exposing a general Rails console.
|
|
49
50
|
email:
|
|
50
51
|
- emogollan@gmail.com
|
|
51
52
|
executables:
|
|
@@ -66,24 +67,34 @@ files:
|
|
|
66
67
|
- lib/coatepec.rb
|
|
67
68
|
- lib/coatepec/cli.rb
|
|
68
69
|
- lib/coatepec/errors.rb
|
|
70
|
+
- lib/coatepec/introspection/bounded_options.rb
|
|
69
71
|
- lib/coatepec/introspection/controller.rb
|
|
70
72
|
- lib/coatepec/introspection/model.rb
|
|
73
|
+
- lib/coatepec/introspection/model_resolver.rb
|
|
74
|
+
- lib/coatepec/introspection/route_entries.rb
|
|
71
75
|
- lib/coatepec/introspection/routes.rb
|
|
72
76
|
- lib/coatepec/introspection/safe_options.rb
|
|
73
77
|
- lib/coatepec/mcp.rb
|
|
78
|
+
- lib/coatepec/mcp/defaults.rb
|
|
74
79
|
- lib/coatepec/mcp/response.rb
|
|
75
80
|
- lib/coatepec/mcp/tools.rb
|
|
76
81
|
- lib/coatepec/project.rb
|
|
77
82
|
- lib/coatepec/project_config.rb
|
|
78
83
|
- lib/coatepec/protocol.rb
|
|
84
|
+
- lib/coatepec/spec/failure_collapser.rb
|
|
79
85
|
- lib/coatepec/spec/flaky_checker.rb
|
|
80
86
|
- lib/coatepec/spec/fork_strategy.rb
|
|
81
87
|
- lib/coatepec/spec/guarded_fork_strategy.rb
|
|
82
88
|
- lib/coatepec/spec/path_policy.rb
|
|
83
89
|
- lib/coatepec/spec/process_strategy.rb
|
|
84
90
|
- lib/coatepec/spec/result.rb
|
|
91
|
+
- lib/coatepec/spec/rspec_adapter.rb
|
|
85
92
|
- lib/coatepec/spec/runner.rb
|
|
86
93
|
- lib/coatepec/spec/spawn_strategy.rb
|
|
94
|
+
- lib/coatepec/test_unit/adapter.rb
|
|
95
|
+
- lib/coatepec/test_unit/child_entry.rb
|
|
96
|
+
- lib/coatepec/test_unit/json_reporter.rb
|
|
97
|
+
- lib/coatepec/test_unit/line_filtering.rb
|
|
87
98
|
- lib/coatepec/version.rb
|
|
88
99
|
- lib/coatepec/worker/change_detector.rb
|
|
89
100
|
- lib/coatepec/worker/client.rb
|
|
@@ -115,5 +126,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
115
126
|
requirements: []
|
|
116
127
|
rubygems_version: 3.6.6
|
|
117
128
|
specification_version: 4
|
|
118
|
-
summary: Run targeted RSpec examples
|
|
129
|
+
summary: Run targeted RSpec examples and introspect Rails structure over MCP, without
|
|
130
|
+
a Rails console
|
|
119
131
|
test_files: []
|