coatepec 0.4.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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Coatepec
6
+ module Spec
7
+ # Turns a finished RSpec child process's exit status, captured
8
+ # stdout/stderr (each capped at MAX_OUTPUT_BYTES), and RSpec's own JSON
9
+ # formatter output into the flat result hash rails_spec_run returns.
10
+ module Result
11
+ MAX_OUTPUT_BYTES = 256 * 1024
12
+
13
+ module_function
14
+
15
+ def build(pid:, status:, out_r:, err_r:, json_path:)
16
+ stdout_result = read_bounded(out_r)
17
+ stderr_result = read_bounded(err_r)
18
+ summary = read_summary(json_path)
19
+
20
+ base(pid, status, stdout_result, stderr_result).merge(
21
+ summary: summary && summary_fields(summary),
22
+ examples: (summary&.fetch("examples", []) || []).first(500).map { |e| example_fields(e) }
23
+ )
24
+ end
25
+
26
+ # rubocop:disable Metrics/MethodLength -- one flat hash literal mapping
27
+ # Process::Status/captured-output fields to the result payload's own
28
+ # field names; splitting it would scatter that 1:1 mapping across
29
+ # methods for no readability gain.
30
+ def base(pid, status, stdout_result, stderr_result)
31
+ {
32
+ status: status.exited? && status.exitstatus.zero? ? "passed" : "failed",
33
+ exit_code: status.exitstatus,
34
+ child_pid: pid,
35
+ signaled: status.signaled?,
36
+ termsig: status.termsig,
37
+ stopsig: status.stopsig,
38
+ coredump: status.respond_to?(:coredump?) ? status.coredump? : false,
39
+ stdout: stdout_result[:text],
40
+ stdout_truncated: stdout_result[:truncated],
41
+ stderr: stderr_result[:text],
42
+ stderr_truncated: stderr_result[:truncated]
43
+ }
44
+ end
45
+ # rubocop:enable Metrics/MethodLength
46
+
47
+ def read_summary(json_path)
48
+ return nil unless File.exist?(json_path) && !File.empty?(json_path)
49
+
50
+ JSON.parse(File.read(json_path))
51
+ end
52
+
53
+ def summary_fields(summary)
54
+ {
55
+ example_count: summary.dig("summary", "example_count"),
56
+ failure_count: summary.dig("summary", "failure_count"),
57
+ duration: summary.dig("summary", "duration")
58
+ }
59
+ end
60
+
61
+ def example_fields(example)
62
+ {
63
+ id: example["id"],
64
+ description: example["full_description"],
65
+ status: example["status"],
66
+ file_path: example["file_path"],
67
+ line_number: example["line_number"]
68
+ }
69
+ end
70
+
71
+ def read_bounded(io)
72
+ data = io.read.to_s
73
+ truncated = data.bytesize > MAX_OUTPUT_BYTES
74
+ data = data.byteslice(-MAX_OUTPUT_BYTES, MAX_OUTPUT_BYTES) if truncated
75
+ { text: data, truncated: truncated }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Spec
5
+ # Validates a `rails_spec_run` request's paths, builds the RSpec CLI
6
+ # args, and delegates to the platform-appropriate process strategy
7
+ # (fork on Linux, spawn on macOS, or a guarded fork on macOS when the
8
+ # project opts in via .coatepec.yml).
9
+ class Runner
10
+ DEFAULT_TIMEOUT = 120
11
+
12
+ def initialize(project_root, rails_runtime: nil)
13
+ @project_root = project_root
14
+ @project = Project.new(project_root)
15
+ @path_policy = PathPolicy.new(@project)
16
+ @rails_runtime = rails_runtime
17
+ end
18
+
19
+ def run(paths:, example: nil, seed: nil, fail_fast: false, timeout_seconds: DEFAULT_TIMEOUT)
20
+ require_rspec!
21
+ selectors = @path_policy.validate!(paths)
22
+ args = build_args(selectors, example, seed, fail_fast)
23
+
24
+ strategy_class.new(@project_root, project: @project, rails_runtime: @rails_runtime).run(args, timeout_seconds)
25
+ end
26
+
27
+ private
28
+
29
+ def strategy_class
30
+ case RbConfig::CONFIG["host_os"]
31
+ when /linux/ then ForkStrategy
32
+ # The macos_fork config key governs this whole branch, BSD included --
33
+ # the name tracks the documented macOS incident, not the platform set.
34
+ when /darwin|bsd/ then macos_strategy_class
35
+ else raise Coatepec::Error.new(:unsupported_platform, "Coatepec supports macOS and Linux only")
36
+ end
37
+ end
38
+
39
+ # Reading @project.config here means an invalid .coatepec.yml only
40
+ # raises :invalid_config on macOS -- the Linux branch never touches it.
41
+ # Accepted asymmetry: the file exists to configure this branch.
42
+ def macos_strategy_class
43
+ @project.config.macos_fork? ? GuardedForkStrategy : SpawnStrategy
44
+ end
45
+
46
+ def require_rspec!
47
+ require "rspec/core"
48
+ rescue LoadError
49
+ raise Coatepec::Error.new(:unsupported_test_framework, "rspec-rails must be in the application's test group")
50
+ end
51
+
52
+ def build_args(selectors, example, seed, fail_fast)
53
+ args = selectors.dup
54
+ args += ["-e", example] if example
55
+ args += ["--seed", seed.to_s] if seed
56
+ args << "--fail-fast" if fail_fast
57
+
58
+ args
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Spec
5
+ # Runs RSpec in a freshly `Process.spawn`ed `bundle exec rspec` (macOS
6
+ # and any platform without a working fork): slower per run since Rails
7
+ # boots from scratch, but avoids fork-safety pitfalls.
8
+ class SpawnStrategy < ProcessStrategy
9
+ private
10
+
11
+ def start(full_args, out_w, err_w)
12
+ Process.spawn(
13
+ { "RAILS_ENV" => "test" }, "bundle", "exec", "rspec", *full_args,
14
+ chdir: @project_root, out: out_w, err: err_w, pgroup: true
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Worker
5
+ # Fingerprints Gemfile/Gemfile.lock and boot-relevant files (mtime +
6
+ # size) so WorkerManager can decide whether a bundle change requires the
7
+ # whole sidecar to restart, or a boot-file change just the worker.
8
+ class ChangeDetector
9
+ BUNDLE_FILES = %w[Gemfile Gemfile.lock].freeze
10
+ BOOT_FILES = %w[config/boot.rb config/application.rb config/environment.rb config/environments/test.rb].freeze
11
+
12
+ def initialize(project_root)
13
+ @project_root = project_root
14
+ end
15
+
16
+ def snapshot
17
+ { bundle: fingerprint(BUNDLE_FILES), boot: fingerprint(BOOT_FILES + initializer_files) }
18
+ end
19
+
20
+ def restart_reason(previous_snapshot)
21
+ current = snapshot
22
+ return :sidecar_restart_required if current[:bundle] != previous_snapshot[:bundle]
23
+ return :worker_restart_required if current[:boot] != previous_snapshot[:boot]
24
+
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ def initializer_files
31
+ Dir.glob(File.join(@project_root, "config/initializers/**/*.rb"))
32
+ .sort
33
+ .map { |f| f.delete_prefix("#{@project_root}/") }
34
+ end
35
+
36
+ def fingerprint(relative_paths)
37
+ relative_paths.each_with_object({}) do |relative_path, acc|
38
+ full_path = File.join(@project_root, relative_path)
39
+ acc[relative_path] = File.exist?(full_path) ? [File.mtime(full_path).to_f, File.size(full_path)] : nil
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module Coatepec
6
+ module Worker
7
+ # The parent-process handle to a spawned test worker: owns its pipes,
8
+ # sends NDJSON requests with a response timeout, and detects when the
9
+ # worker has died.
10
+ class Client
11
+ class DisconnectedError < StandardError; end
12
+
13
+ def self.spawn(project_root)
14
+ new(project_root).tap(&:start)
15
+ end
16
+
17
+ def initialize(project_root)
18
+ @project_root = project_root
19
+ @next_id = 0
20
+ end
21
+
22
+ def start
23
+ to_worker_r, @to_worker_write = IO.pipe
24
+ @from_worker_read, from_worker_w = IO.pipe
25
+
26
+ @pid = spawn_worker(to_worker_r, from_worker_w)
27
+ to_worker_r.close
28
+ from_worker_w.close
29
+ @protocol = Protocol.new(input: @from_worker_read, output: @to_worker_write)
30
+ end
31
+
32
+ def alive?
33
+ return false unless @pid
34
+
35
+ Process.kill(0, @pid)
36
+ true
37
+ rescue Errno::ESRCH
38
+ false
39
+ end
40
+
41
+ def request(command, args, timeout: 30)
42
+ raise DisconnectedError, "Worker is not running" unless alive?
43
+
44
+ id = (@next_id += 1)
45
+ @protocol.write(id: id, command: command, args: args)
46
+ message = read_response(id, timeout)
47
+
48
+ message[:ok] ? message[:data] : raise_worker_error(message[:error])
49
+ end
50
+
51
+ def stop
52
+ return unless @pid
53
+
54
+ Process.kill("TERM", @pid)
55
+ Process.wait(@pid)
56
+ rescue Errno::ESRCH, Errno::ECHILD
57
+ nil
58
+ ensure
59
+ [@to_worker_write, @from_worker_read].each { |io| io && !io.closed? && io.close }
60
+ @pid = nil
61
+ end
62
+
63
+ private
64
+
65
+ def spawn_worker(to_worker_r, from_worker_w)
66
+ worker_exe = File.expand_path("../../../exe/coatepec-worker", __dir__)
67
+ lib_path = File.expand_path("../..", __dir__)
68
+ # If this process is itself running under a Bundler context (e.g. the
69
+ # host app's own deployment-mode bundle), Bundler.setup has already
70
+ # narrowed GEM_PATH/RUBYOPT to that bundle's install location. Without
71
+ # stripping that, the worker -- which needs the target app's own
72
+ # separately-installed gems -- inherits a GEM_PATH that can't see them
73
+ # and fails with a spurious Bundler::GemNotFound. with_unbundled_env
74
+ # also resets RUBYLIB to its pre-Bundler snapshot, so it must be set
75
+ # explicitly here rather than relying on the caller's environment.
76
+ Bundler.with_unbundled_env do
77
+ Process.spawn(
78
+ { "BUNDLE_GEMFILE" => File.join(@project_root, "Gemfile"), "RUBYLIB" => lib_path },
79
+ RbConfig.ruby, worker_exe, @project_root,
80
+ in: to_worker_r, out: from_worker_w, err: :err, chdir: @project_root
81
+ )
82
+ end
83
+ end
84
+
85
+ def read_response(id, timeout)
86
+ ready = IO.select([@from_worker_read], nil, nil, timeout)
87
+ raise DisconnectedError, "Worker timed out responding" unless ready
88
+
89
+ message = @protocol.read
90
+ raise DisconnectedError, "Worker closed the connection" if message.nil?
91
+ raise DisconnectedError, "Unexpected response id #{message[:id]} for request #{id}" unless message[:id] == id
92
+
93
+ message
94
+ end
95
+
96
+ def raise_worker_error(error)
97
+ raise Coatepec::Error.new(error[:code].to_sym, error[:message], details: error[:details] || {})
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Coatepec
6
+ module Worker
7
+ # Boots the fixture/target Rails app under RAILS_ENV=test exactly once
8
+ # per worker process and reports its identity (pid, boot_id, versions,
9
+ # lifecycle state) for rails_runtime_status.
10
+ class RailsRuntime
11
+ attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count
12
+
13
+ def initialize(project_root)
14
+ @project_root = project_root
15
+ @booted = false
16
+ end
17
+
18
+ def booted?
19
+ @booted
20
+ end
21
+
22
+ def boot!
23
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ ENV["RAILS_ENV"] = "test"
25
+ force_reloading!
26
+ require File.join(@project_root, "config/environment")
27
+ record_boot!(started_at)
28
+ rescue Coatepec::Error
29
+ raise
30
+ rescue StandardError, LoadError => e
31
+ raise Coatepec::Error.new(:worker_failure, "Rails failed to boot: #{e.message}")
32
+ end
33
+
34
+ def status
35
+ {
36
+ pid: pid,
37
+ boot_id: boot_id,
38
+ ruby_version: ruby_version,
39
+ rails_version: rails_version,
40
+ boot_duration_ms: boot_duration_ms,
41
+ environment: "test",
42
+ coatepec_version: Coatepec::VERSION,
43
+ lifecycle_state: booted? ? "ready" : "not_started"
44
+ }
45
+ end
46
+
47
+ # Loaded gem names for GuardedForkStrategy's denylist check. RailsRuntime
48
+ # itself has no notion of macOS or forking -- it only exposes the raw
49
+ # fact of what's loaded.
50
+ def loaded_gem_names
51
+ Gem.loaded_specs.keys.map(&:to_s)
52
+ end
53
+
54
+ private
55
+
56
+ # Rails' test environment disables reloading by default, and Zeitwerk
57
+ # refuses to enable reloading on a loader that's already had #setup
58
+ # called on it -- which happens automatically, deep inside
59
+ # Rails.application.initialize!, before any of our own code can run.
60
+ # The only window to influence this is before the target app's own
61
+ # config/environment.rb executes: intercept the config setter itself,
62
+ # so whatever the app's test.rb assigns, reloading still ends up on.
63
+ #
64
+ # `require "bundler/setup"` here (before `require "rails"`) is
65
+ # necessary, not optional: without it, `require "rails"` resolves
66
+ # whatever Rails version RubyGems activates by default -- not
67
+ # necessarily the one pinned by the target app's own Gemfile.lock --
68
+ # and once the wrong version's gems are activated process-wide, the
69
+ # target app's own later `require "bundler/setup"` (in its
70
+ # config/boot.rb) raises a Gem::LoadError version conflict instead of
71
+ # silently no-oping. BUNDLE_GEMFILE is already set to the target app's
72
+ # Gemfile by the caller (Worker::Client's spawn env) before this
73
+ # process starts, so this activates the correct version and the app's
74
+ # own later Bundler.setup call is simply a no-op.
75
+ def force_reloading!
76
+ require "bundler/setup"
77
+ require "rails"
78
+ return if Rails::Application::Configuration.method_defined?(:coatepec_forces_reloading?)
79
+
80
+ Rails::Application::Configuration.prepend(reload_forcing_module)
81
+ Rails::Application::Configuration.prepend(file_watcher_forcing_module)
82
+ end
83
+
84
+ # `enable_reloading=` is overridden as the modern, documented setter.
85
+ # `cache_classes=` is also overridden because Rails' own
86
+ # `enable_reloading=` is implemented as `self.cache_classes = !value`
87
+ # (see railties' application/configuration.rb) with `cache_classes`
88
+ # remaining a plain attr_accessor underneath -- so a target app's
89
+ # config/environments/test.rb using the legacy `config.cache_classes =
90
+ # true` form would otherwise write that flag directly, bypassing the
91
+ # `enable_reloading=` override entirely and silently leaving reloading
92
+ # off.
93
+ def reload_forcing_module
94
+ Module.new do
95
+ def coatepec_forces_reloading? = true
96
+
97
+ def enable_reloading=(_value)
98
+ super(true)
99
+ end
100
+
101
+ def cache_classes=(_value)
102
+ super(false)
103
+ end
104
+ end
105
+ end
106
+
107
+ # Consequence of the override above, and prepended alongside it: with
108
+ # reloading on, Rails' own finisher now instantiates
109
+ # `config.file_watcher` -- something that never happened in this process
110
+ # while the test env kept reloading off. The default
111
+ # `ActiveSupport::FileUpdateChecker` polls on the calling thread and is
112
+ # fork-safe, but a target app configuring
113
+ # `ActiveSupport::EventedFileUpdateChecker` would instead spawn `listen`
114
+ # background threads *during boot* in the warm worker. On macOS those
115
+ # are exactly the ObjC-initializing threads GuardedForkStrategy exists
116
+ # to keep out of a fork -- and because they'd start before
117
+ # #post_boot_thread_count is sampled, they'd be baked into the guard's
118
+ # own baseline and sail through its thread-count check unnoticed. So the
119
+ # watcher is pinned to the polling implementation regardless of what the
120
+ # app asks for.
121
+ def file_watcher_forcing_module
122
+ Module.new do
123
+ def file_watcher=(_value)
124
+ super(::ActiveSupport::FileUpdateChecker)
125
+ end
126
+ end
127
+ end
128
+
129
+ def record_boot!(started_at)
130
+ @pid = Process.pid
131
+ @boot_id = SecureRandom.hex(8)
132
+ @ruby_version = RUBY_VERSION
133
+ @rails_version = Rails.version
134
+ @boot_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
135
+ @post_boot_thread_count = Thread.list.count
136
+ @booted = true
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Worker
5
+ # The test worker's message loop: reads NDJSON requests from its parent
6
+ # over Protocol, lazily boots Rails on first use, dispatches `status`/
7
+ # `spec_run`, and writes back structured ok/error responses.
8
+ class Server
9
+ def initialize(project_root, input:, protocol_output:)
10
+ @protocol = Protocol.new(input: input, output: protocol_output)
11
+ @runtime = RailsRuntime.new(project_root)
12
+ @project_root = project_root
13
+ end
14
+
15
+ def run
16
+ loop do
17
+ message = @protocol.read
18
+ break if message.nil?
19
+
20
+ handle(message)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def handle(message)
27
+ @runtime.boot! unless @runtime.booted?
28
+
29
+ data = dispatch(message[:command], message[:args] || {})
30
+ @protocol.write(id: message[:id], ok: true, data: data)
31
+ rescue Coatepec::Error => e
32
+ @protocol.write(id: message[:id], ok: false, error: { code: e.code, message: e.message, details: e.details })
33
+ rescue StandardError => e
34
+ @protocol.write(id: message[:id], ok: false, error: { code: :internal_error, message: e.message, details: {} })
35
+ end
36
+
37
+ def dispatch(command, args)
38
+ Rails.application.reloader.wrap { execute_command(command, args) }
39
+ end
40
+
41
+ def execute_command(command, args)
42
+ case command
43
+ when "status" then @runtime.status
44
+ when "spec_run" then handle_spec_run(args)
45
+ when "routes" then handle_routes(args)
46
+ when "model" then handle_model(args)
47
+ else
48
+ raise Coatepec::Error.new(:internal_error, "Unknown command #{command}")
49
+ end
50
+ end
51
+
52
+ def handle_spec_run(args)
53
+ Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
54
+ end
55
+
56
+ def handle_routes(args)
57
+ Introspection::Routes.new(**args.transform_keys(&:to_sym)).call
58
+ end
59
+
60
+ def handle_model(args)
61
+ Introspection::Model.new(args.transform_keys(&:to_sym)[:name]).call
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Coatepec
6
+ # Owns the single warm Worker::Client for a project: lazily starts it,
7
+ # restarts it when ChangeDetector flags a boot-file change, escalates a
8
+ # Gemfile change to :sidecar_restart_required, and retries a request once
9
+ # if the worker had died since the last dispatch.
10
+ class WorkerManager
11
+ def initialize(project)
12
+ @project = project
13
+ @change_detector = Worker::ChangeDetector.new(project.root)
14
+ @client = nil
15
+ @snapshot = nil
16
+ @lock = Monitor.new
17
+ end
18
+
19
+ def status
20
+ dispatch("status", {}, timeout: 30)
21
+ end
22
+
23
+ def run_spec(paths:, example:, seed:, fail_fast:, timeout_seconds:)
24
+ dispatch(
25
+ "spec_run",
26
+ { paths: paths, example: example, seed: seed, fail_fast: fail_fast, timeout_seconds: timeout_seconds },
27
+ timeout: timeout_seconds + 10
28
+ )
29
+ end
30
+
31
+ def routes(query: nil, limit: 50, offset: 0)
32
+ dispatch("routes", { query: query, limit: limit, offset: offset }, timeout: 30)
33
+ end
34
+
35
+ def model(name:)
36
+ dispatch("model", { name: name }, timeout: 30)
37
+ end
38
+
39
+ def stop
40
+ @lock.synchronize { @client&.stop }
41
+ end
42
+
43
+ private
44
+
45
+ def dispatch(command, args, timeout:, retried: false)
46
+ @lock.synchronize do
47
+ # The restart check must run against the *pre-existing* snapshot, before
48
+ # ensure_worker! can re-baseline it by booting a fresh worker: a dead
49
+ # worker plus a changed Gemfile must still surface
50
+ # :sidecar_restart_required rather than silently adopting the new bundle.
51
+ # There is nothing to compare against on the very first dispatch.
52
+ check_for_restart! if @snapshot
53
+ ensure_worker!
54
+ perform(command, args, timeout: timeout, retried: retried)
55
+ end
56
+ end
57
+
58
+ def perform(command, args, timeout:, retried:)
59
+ @client.request(command, args, timeout: timeout)
60
+ rescue Worker::Client::DisconnectedError
61
+ raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected") if retried
62
+
63
+ restart_worker!
64
+ dispatch(command, args, timeout: timeout, retried: true)
65
+ end
66
+
67
+ def check_for_restart!
68
+ reason = @change_detector.restart_reason(@snapshot)
69
+ case reason
70
+ when :sidecar_restart_required
71
+ raise Coatepec::Error.new(:sidecar_restart_required, "Gemfile changed; restart Coatepec")
72
+ when :worker_restart_required
73
+ restart_worker!
74
+ end
75
+ end
76
+
77
+ def ensure_worker!
78
+ restart_worker! unless @client&.alive?
79
+ end
80
+
81
+ def restart_worker!
82
+ @client&.stop
83
+ @client = Worker::Client.spawn(@project.root)
84
+ @snapshot = @change_detector.snapshot
85
+ end
86
+ end
87
+ end
data/lib/coatepec.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coatepec/version"
4
+ require_relative "coatepec/errors"
5
+ require_relative "coatepec/project_config"
6
+ require_relative "coatepec/project"
7
+ require_relative "coatepec/spec/path_policy"
8
+ require_relative "coatepec/protocol"
9
+ require_relative "coatepec/worker/change_detector"
10
+ require_relative "coatepec/worker/client"
11
+ require_relative "coatepec/worker/rails_runtime"
12
+ require_relative "coatepec/introspection/routes"
13
+ require_relative "coatepec/introspection/safe_options"
14
+ require_relative "coatepec/introspection/model"
15
+ require_relative "coatepec/spec/result"
16
+ require_relative "coatepec/spec/process_strategy"
17
+ require_relative "coatepec/spec/fork_strategy"
18
+ require_relative "coatepec/spec/spawn_strategy"
19
+ require_relative "coatepec/spec/guarded_fork_strategy"
20
+ require_relative "coatepec/spec/runner"
21
+ require_relative "coatepec/worker/server"
22
+ require_relative "coatepec/worker_manager"
23
+
24
+ # Coatepec is a local stdio MCP sidecar that keeps an isolated Rails test
25
+ # worker warm so coding agents can run targeted RSpec examples quickly,
26
+ # without exposing a general Rails console.
27
+ module Coatepec
28
+ end
data/sig/coatepec.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Coatepec
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end