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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coatepec
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -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 @runtime.status
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
- Introspection::Model.new(args.transform_keys(&:to_sym)[:name]).call
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: 50, offset: 0)
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.7.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-04 00:00:00.000000000 Z
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, without exposing
48
- a general Rails console.
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 against a warm Rails test worker over MCP
129
+ summary: Run targeted RSpec examples and introspect Rails structure over MCP, without
130
+ a Rails console
119
131
  test_files: []