coatepec 0.7.0 → 0.8.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 +111 -0
- data/README.md +107 -31
- data/ROADMAP.md +28 -62
- 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/response.rb +16 -9
- data/lib/coatepec/mcp/tools.rb +85 -90
- data/lib/coatepec/mcp.rb +2 -2
- data/lib/coatepec/project.rb +11 -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 +3 -12
- data/lib/coatepec/spec/guarded_fork_strategy.rb +11 -10
- data/lib/coatepec/spec/path_policy.rb +45 -12
- data/lib/coatepec/spec/process_strategy.rb +22 -17
- data/lib/coatepec/spec/result.rb +61 -19
- data/lib/coatepec/spec/rspec_adapter.rb +58 -0
- data/lib/coatepec/spec/runner.rb +22 -24
- data/lib/coatepec/spec/spawn_strategy.rb +7 -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/server.rb +2 -1
- data/lib/coatepec/worker_manager.rb +8 -6
- data/lib/coatepec.rb +5 -0
- metadata +16 -5
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "model_resolver"
|
|
4
|
+
|
|
3
5
|
module Coatepec
|
|
4
6
|
module Introspection
|
|
5
7
|
# Returns bounded ActiveRecord schema and class metadata for a single
|
|
@@ -7,45 +9,48 @@ module Coatepec
|
|
|
7
9
|
# reflection, no method dispatch on the resolved class beyond pure
|
|
8
10
|
# introspection APIs.
|
|
9
11
|
class Model
|
|
10
|
-
|
|
12
|
+
FIELDS = %w[columns associations validators enums].freeze
|
|
11
13
|
MAX_ITEMS = 200
|
|
14
|
+
MAX_OPTION_VALUES = 20
|
|
12
15
|
EMPTY_TABLE_METADATA = { table_name: nil, primary_key: nil, columns: [] }.freeze
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
# Counts are always returned; lists are opt-in, so an omitted fields costs a few dozen bytes.
|
|
18
|
+
def initialize(name, fields: nil)
|
|
15
19
|
@name = name
|
|
20
|
+
@fields = validate_fields!(fields || [])
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
def call
|
|
19
|
-
|
|
20
|
-
klass = resolve!
|
|
21
|
-
validate_active_record!(klass)
|
|
24
|
+
klass = ModelResolver.call(@name)
|
|
22
25
|
build_metadata(klass)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
private
|
|
26
29
|
|
|
27
|
-
# An abstract class (e.g. ApplicationRecord) has no real table, so
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
30
|
+
# An abstract class (e.g. ApplicationRecord) has no real table, so table_name/primary_key/columns all raise
|
|
31
|
+
# if called against it -- report empty/nil table data instead. abstract_class? is nil (not false) for a
|
|
32
|
+
# concrete class on Rails 7.1 and false on 8.1, so normalize it for version-stable JSON. enums, like
|
|
33
|
+
# validators, are in-memory class metadata needing no connection, so they are always collected.
|
|
31
34
|
def build_metadata(klass)
|
|
32
|
-
# abstract_class? is a plain attr_accessor-backed predicate that is
|
|
33
|
-
# never assigned on concrete subclasses -- on Rails 7.1 it returns
|
|
34
|
-
# nil (not false) in that case, while Rails 8.1 returns false.
|
|
35
|
-
# Normalize to a genuine Boolean so JSON output is version-stable.
|
|
36
35
|
abstract = klass.abstract_class? || false
|
|
37
36
|
table = abstract ? EMPTY_TABLE_METADATA : table_metadata(klass)
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
sections = { columns: table[:columns], associations: abstract ? [] : associations_for(klass),
|
|
38
|
+
validators: validators_for(klass), enums: enums_for(klass) }
|
|
39
|
+
{ name: klass.name, table_name: table[:table_name], primary_key: table[:primary_key], abstract_class: abstract,
|
|
40
|
+
counts: sections.transform_values(&:size) }.merge(sections.slice(*(FIELDS & @fields).map(&:to_sym)))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The MCP schema enforces the enum; this guards the worker command against any other caller.
|
|
44
|
+
def validate_fields!(fields)
|
|
45
|
+
unless fields.is_a?(Array)
|
|
46
|
+
raise Coatepec::Error.new(:invalid_model_fields, "fields must be an array, got #{fields.inspect}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unknown = fields - FIELDS
|
|
50
|
+
return fields if unknown.empty?
|
|
51
|
+
|
|
52
|
+
raise Coatepec::Error.new(:invalid_model_fields,
|
|
53
|
+
"fields must be among #{FIELDS.join(", ")}, got #{unknown.inspect}")
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
# A concrete model can still name a table that isn't there: a checkout
|
|
@@ -68,23 +73,6 @@ module Coatepec
|
|
|
68
73
|
)
|
|
69
74
|
end
|
|
70
75
|
|
|
71
|
-
def validate_name!
|
|
72
|
-
return if @name.is_a?(String) && NAME_PATTERN.match?(@name)
|
|
73
|
-
|
|
74
|
-
raise Coatepec::Error.new(:invalid_model_name, "#{@name.inspect} is not a valid constant name")
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def resolve!
|
|
78
|
-
::ActiveSupport::Inflector.safe_constantize(@name) ||
|
|
79
|
-
raise(Coatepec::Error.new(:model_not_found, "#{@name} could not be resolved"))
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def validate_active_record!(klass)
|
|
83
|
-
return if klass.is_a?(Class) && klass < ::ActiveRecord::Base
|
|
84
|
-
|
|
85
|
-
raise Coatepec::Error.new(:not_active_record_model, "#{@name} is not an ActiveRecord model")
|
|
86
|
-
end
|
|
87
|
-
|
|
88
76
|
def columns_for(klass)
|
|
89
77
|
klass.columns.first(MAX_ITEMS).map do |column|
|
|
90
78
|
{ name: column.name, type: column.type.to_s, sql_type: column.sql_type, null: column.null,
|
|
@@ -159,12 +147,14 @@ module Coatepec
|
|
|
159
147
|
nil
|
|
160
148
|
end
|
|
161
149
|
|
|
150
|
+
# Rails instantiates one validator per declaration; a concern and the model body often declare the same one.
|
|
151
|
+
# De-duplicate on the raw validator: SafeOptions drops the Procs and Regexps that tell two apart.
|
|
162
152
|
def validators_for(klass)
|
|
163
|
-
klass.validators.first(MAX_ITEMS).map do |validator|
|
|
153
|
+
klass.validators.uniq { |v| [v.class.name, v.attributes, v.options] }.first(MAX_ITEMS).map do |validator|
|
|
164
154
|
{
|
|
165
155
|
name: validator.class.name,
|
|
166
156
|
attributes: validator.attributes.map(&:to_s),
|
|
167
|
-
options: SafeOptions.call(validator.options)
|
|
157
|
+
options: BoundedOptions.call(SafeOptions.call(validator.options), MAX_OPTION_VALUES)
|
|
168
158
|
}
|
|
169
159
|
end
|
|
170
160
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Introspection
|
|
5
|
+
# Turns a rails_model name into an ActiveRecord class, or raises the structured error that says why not.
|
|
6
|
+
module ModelResolver
|
|
7
|
+
NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(name)
|
|
12
|
+
validate_name!(name)
|
|
13
|
+
klass = ::ActiveSupport::Inflector.safe_constantize(name) ||
|
|
14
|
+
raise(Coatepec::Error.new(:model_not_found, "#{name} could not be resolved"))
|
|
15
|
+
validate_active_record!(name, klass)
|
|
16
|
+
klass
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_name!(name)
|
|
20
|
+
return if name.is_a?(String) && NAME_PATTERN.match?(name)
|
|
21
|
+
|
|
22
|
+
raise Coatepec::Error.new(:invalid_model_name, "#{name.inspect} is not a valid constant name")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_active_record!(name, klass)
|
|
26
|
+
return if klass.is_a?(Class) && klass < ::ActiveRecord::Base
|
|
27
|
+
|
|
28
|
+
raise Coatepec::Error.new(:not_active_record_model, "#{name} is not an ActiveRecord model")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Introspection
|
|
5
|
+
# Flattens a route set into application routes followed by one level of mounted-engine
|
|
6
|
+
# routes with the mount path prefixed, the way `bin/rails routes` shows them.
|
|
7
|
+
class RouteEntries
|
|
8
|
+
Entry = Struct.new(:route, :path, :engine)
|
|
9
|
+
|
|
10
|
+
def self.call(routes)
|
|
11
|
+
new(routes).call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(routes)
|
|
15
|
+
@routes = routes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
visible = @routes.reject { |route| internal?(route) }
|
|
20
|
+
|
|
21
|
+
visible.map { |route| Entry.new(route, path_of(route), nil) } +
|
|
22
|
+
visible.flat_map { |route| engine_entries(route) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Rails' own /rails/info routes; `bin/rails routes` hides them too. Guarded so unit-spec structs work.
|
|
28
|
+
def internal?(route)
|
|
29
|
+
route.respond_to?(:internal) && route.internal
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# One level only, like RoutesInspector; an engine mounted at two paths appears under both.
|
|
33
|
+
def engine_entries(route)
|
|
34
|
+
return [] unless engine_mount?(route)
|
|
35
|
+
|
|
36
|
+
engine = route.app.rack_app
|
|
37
|
+
prefix = mount_prefix(route)
|
|
38
|
+
name = engine_name(engine)
|
|
39
|
+
inner_routes(engine).reject { |inner| internal?(inner) }
|
|
40
|
+
.map { |inner| Entry.new(inner, prefix + path_of(inner), name) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `(.:format)` is on nearly every route and says nothing a caller acts on; `bin/rails routes` shows it, we don't.
|
|
44
|
+
def path_of(route)
|
|
45
|
+
route.path.spec.to_s.sub(/\(\.:format\)\z/, "")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# chomp only affects a mount at "/", whose junction is the one place a "//" could form.
|
|
49
|
+
def mount_prefix(route)
|
|
50
|
+
path_of(route).chomp("/")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Only a ::Rails::Engine subclass is expanded or named; nothing else ever gets .name called on it.
|
|
54
|
+
def engine_mount?(route)
|
|
55
|
+
route.respond_to?(:app) && route.app.respond_to?(:engine?) && route.app.engine? &&
|
|
56
|
+
rails_engine?(route.app.rack_app)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def rails_engine?(rack_app)
|
|
60
|
+
defined?(::Rails::Engine) && rack_app.is_a?(Class) && rack_app < ::Rails::Engine
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Class.new(Rails::Engine) has a nil name; a fixed label keeps its routes tagged and boot-stable.
|
|
64
|
+
def engine_name(engine)
|
|
65
|
+
engine.name || "(anonymous engine)"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# engine.routes is a RouteSet whose .routes holds the Journey routes; anything else stays unflattened.
|
|
69
|
+
def inner_routes(engine)
|
|
70
|
+
return [] unless engine.respond_to?(:routes)
|
|
71
|
+
|
|
72
|
+
route_set = engine.routes
|
|
73
|
+
route_set.respond_to?(:routes) ? route_set.routes : []
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -1,33 +1,69 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "route_entries"
|
|
4
|
+
|
|
3
5
|
module Coatepec
|
|
4
6
|
module Introspection
|
|
5
|
-
# Returns a bounded, filterable list of the target
|
|
6
|
-
#
|
|
7
|
-
# only -- no console, no request dispatch.
|
|
7
|
+
# Returns a bounded, filterable list of the target app's routes for the rails_routes MCP tool: application
|
|
8
|
+
# routes by default, mounted-engine routes on request. Reads route tables only -- no console, no dispatch.
|
|
8
9
|
class Routes
|
|
9
10
|
MAX_LIMIT = 200
|
|
10
|
-
DEFAULT_LIMIT =
|
|
11
|
+
DEFAULT_LIMIT = 100
|
|
12
|
+
ENGINE_FILTERS = %w[include exclude only].freeze
|
|
13
|
+
COLUMNS = %w[name verb path controller action engine].freeze
|
|
14
|
+
COLUMN_KEYS = COLUMNS.map(&:to_sym).freeze
|
|
15
|
+
# Engine CRUD scaffolding was two thirds of a typical match; the payload states what the default withheld.
|
|
16
|
+
DEFAULT_ENGINES = "exclude"
|
|
11
17
|
|
|
12
|
-
def initialize(query: nil, limit: DEFAULT_LIMIT, offset: 0)
|
|
18
|
+
def initialize(query: nil, limit: DEFAULT_LIMIT, offset: 0, engines: DEFAULT_ENGINES)
|
|
13
19
|
@query = query
|
|
14
20
|
@limit = [limit || DEFAULT_LIMIT, MAX_LIMIT].min
|
|
15
21
|
@offset = offset || 0
|
|
22
|
+
@engines = validate_engines!(engines || DEFAULT_ENGINES)
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
def call
|
|
19
|
-
matched =
|
|
26
|
+
matched, excluded = partitioned_items
|
|
27
|
+
page(matched, excluded)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Rows instead of one hash per route: the six key names were a third of every item's bytes.
|
|
33
|
+
def page(matched, excluded)
|
|
20
34
|
{
|
|
21
|
-
|
|
35
|
+
columns: COLUMNS,
|
|
36
|
+
rows: (matched[@offset, @limit] || []).map { |item| item.values_at(*COLUMN_KEYS) },
|
|
22
37
|
matched: matched.size,
|
|
23
38
|
limit: @limit,
|
|
24
|
-
offset: @offset
|
|
39
|
+
offset: @offset,
|
|
40
|
+
next_offset: next_offset_for(matched.size),
|
|
41
|
+
engines: @engines,
|
|
42
|
+
engines_excluded: excluded.size
|
|
25
43
|
}
|
|
26
44
|
end
|
|
27
45
|
|
|
28
|
-
|
|
46
|
+
# The MCP schema already enforces the enum; this guards the worker command against any other caller.
|
|
47
|
+
def validate_engines!(value)
|
|
48
|
+
return value if ENGINE_FILTERS.include?(value)
|
|
49
|
+
|
|
50
|
+
raise Coatepec::Error.new(:invalid_engines_filter,
|
|
51
|
+
"engines must be one of #{ENGINE_FILTERS.join(", ")}, got #{value.inspect}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Spelled out so a caller never has to infer a follow-up page from matched > limit.
|
|
55
|
+
# candidate > @offset guards limit 0, which would otherwise hand back the same offset forever.
|
|
56
|
+
def next_offset_for(matched_count)
|
|
57
|
+
candidate = @offset + @limit
|
|
58
|
+
candidate > @offset && candidate < matched_count ? candidate : nil
|
|
59
|
+
end
|
|
29
60
|
|
|
30
|
-
|
|
61
|
+
# Query first, then the engine filter, so engines_excluded counts routes this exact query would have shown.
|
|
62
|
+
def partitioned_items
|
|
63
|
+
query_matches.partition { |item| engine_selected?(item) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def query_matches
|
|
31
67
|
items = all_items
|
|
32
68
|
return items unless @query
|
|
33
69
|
|
|
@@ -35,19 +71,33 @@ module Coatepec
|
|
|
35
71
|
items.select { |item| item.values.compact.any? { |v| v.to_s.downcase.include?(query_downcased) } }
|
|
36
72
|
end
|
|
37
73
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
path: route.path.spec.to_s,
|
|
45
|
-
controller: defaults[:controller]&.to_s,
|
|
46
|
-
action: defaults[:action]&.to_s
|
|
47
|
-
}
|
|
74
|
+
# An engine's mount route has engine: nil -- it is an application route and stays under "exclude".
|
|
75
|
+
def engine_selected?(item)
|
|
76
|
+
case @engines
|
|
77
|
+
when "exclude" then item[:engine].nil?
|
|
78
|
+
when "only" then !item[:engine].nil?
|
|
79
|
+
else true
|
|
48
80
|
end
|
|
49
81
|
end
|
|
50
82
|
|
|
83
|
+
def all_items
|
|
84
|
+
RouteEntries.call(self.class.rails_routes).map { |entry| item_for(entry) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# path comes off the entry: for an engine route it already carries the mount prefix.
|
|
88
|
+
def item_for(entry)
|
|
89
|
+
route = entry.route
|
|
90
|
+
defaults = route.defaults
|
|
91
|
+
{
|
|
92
|
+
name: route.name&.to_s,
|
|
93
|
+
verb: route.verb.to_s,
|
|
94
|
+
path: entry.path,
|
|
95
|
+
controller: defaults[:controller]&.to_s,
|
|
96
|
+
action: defaults[:action]&.to_s,
|
|
97
|
+
engine: entry.engine
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
51
101
|
# Isolated as a class method purely so unit tests can stub it without
|
|
52
102
|
# booting Rails -- Rails.application.routes.routes is otherwise only
|
|
53
103
|
# reachable with a real, booted application.
|
|
@@ -5,20 +5,17 @@ require "mcp"
|
|
|
5
5
|
|
|
6
6
|
module Coatepec
|
|
7
7
|
module MCP
|
|
8
|
-
# Builds the
|
|
9
|
-
#
|
|
10
|
-
# text content block.
|
|
8
|
+
# Builds the compact JSON `{ok: true, data:, meta:}` / `{ok: false, error:}`
|
|
9
|
+
# envelope every tool response wraps in one text block.
|
|
11
10
|
module Response
|
|
12
|
-
# Per-stream caps in Spec::Result bound stdout/stderr, but
|
|
13
|
-
#
|
|
14
|
-
# exceed what an MCP client will accept, so cap it here -- the single
|
|
15
|
-
# place every tool response is built.
|
|
11
|
+
# Per-stream caps in Spec::Result bound stdout/stderr, but a fully assembled
|
|
12
|
+
# envelope can still exceed what an MCP client accepts, so cap it here.
|
|
16
13
|
MAX_RESPONSE_BYTES = 1024 * 1024
|
|
17
14
|
|
|
18
15
|
module_function
|
|
19
16
|
|
|
20
17
|
def ok(data:, meta: {})
|
|
21
|
-
text =
|
|
18
|
+
text = generate(ok: true, data: data, meta: meta)
|
|
22
19
|
return oversized_response if text.bytesize > MAX_RESPONSE_BYTES
|
|
23
20
|
|
|
24
21
|
::MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
@@ -29,12 +26,22 @@ module Coatepec
|
|
|
29
26
|
ok: false,
|
|
30
27
|
error: { code: err.code.to_s, message: err.message, details: err.details }
|
|
31
28
|
}
|
|
32
|
-
::MCP::Tool::Response.new([{ type: "text", text:
|
|
29
|
+
::MCP::Tool::Response.new([{ type: "text", text: generate(payload) }], error: true)
|
|
33
30
|
end
|
|
34
31
|
|
|
35
32
|
def oversized_response
|
|
36
33
|
error(Coatepec::Error.new(:response_too_large, "Result exceeds the 1 MiB response limit"))
|
|
37
34
|
end
|
|
35
|
+
|
|
36
|
+
# Compact by default: nothing downstream reads the indentation. COATEPEC_PRETTY=1 restores it for hand debugging.
|
|
37
|
+
def generate(payload)
|
|
38
|
+
ENV["COATEPEC_PRETTY"] == "1" ? JSON.pretty_generate(payload) : JSON.generate(payload)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Per-call timing is the only per-call fact; project_root and environment live on rails_runtime_status.
|
|
42
|
+
def meta(started_at)
|
|
43
|
+
{ duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round }
|
|
44
|
+
end
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
end
|