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,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Introspection
|
|
5
|
+
# Caps the array-valued entries of a sanitized validator options hash (see SafeOptions) for rails_model output:
|
|
6
|
+
# 249 country codes just say "allow-list", and the count plus flag beside a cut list keep it self-describing.
|
|
7
|
+
module BoundedOptions
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(options, max)
|
|
11
|
+
options.each_with_object({}) do |(key, value), bounded|
|
|
12
|
+
bounded[key] = value
|
|
13
|
+
next unless value.is_a?(Array) && value.size > max
|
|
14
|
+
|
|
15
|
+
bounded[key] = value.first(max)
|
|
16
|
+
bounded["#{key}_count"] = value.size
|
|
17
|
+
bounded["#{key}_truncated"] = true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "route_entries"
|
|
4
|
+
|
|
3
5
|
module Coatepec
|
|
4
6
|
module Introspection
|
|
5
7
|
# Returns bounded ActionController class metadata for a single controller,
|
|
6
8
|
# for the rails_controller MCP tool. Pure reflection over an already-loaded,
|
|
7
9
|
# already-gated class -- no request dispatch, no action execution, and no
|
|
8
10
|
# evaluation of app-authored callback conditions.
|
|
9
|
-
# rubocop:disable Metrics/ClassLength --
|
|
10
|
-
#
|
|
11
|
-
# route_data, and rails_routes exist solely to serve this class's single
|
|
12
|
-
# responsibility (reflect on one controller). Splitting them into a
|
|
13
|
-
# separate collaborator class would fragment that one responsibility
|
|
14
|
-
# across files for no readability gain, only to satisfy a line count.
|
|
11
|
+
# rubocop:disable Metrics/ClassLength -- the route cross-referencing methods serve this class's
|
|
12
|
+
# one responsibility (reflect on one controller); splitting them out would only satisfy a line count.
|
|
15
13
|
class Controller
|
|
16
14
|
NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
17
15
|
MAX_ITEMS = 200
|
|
@@ -101,25 +99,20 @@ module Coatepec
|
|
|
101
99
|
end
|
|
102
100
|
end
|
|
103
101
|
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# "admin/reports"), so matching on it needs no name munging.
|
|
107
|
-
#
|
|
108
|
-
# Only Rails.application.routes is read, so a controller mounted inside
|
|
109
|
-
# an engine will report its actions as unroutable even though the
|
|
110
|
-
# engine's own route set reaches them. Introspection::Routes has exactly
|
|
111
|
-
# the same boundary today; it is documented in the README rather than
|
|
112
|
-
# silently absorbed.
|
|
113
|
-
# A route whose defaults[:action] is nil or empty (a mount or redirect)
|
|
114
|
-
# is skipped, not recorded under an empty-string action.
|
|
102
|
+
# Matches on controller_path, Rails' own key in route defaults ("admin/reports"), through
|
|
103
|
+
# RouteEntries so engine controllers resolve too. Routes with no action (mounts, redirects) are skipped.
|
|
115
104
|
def routes_by_action(klass)
|
|
116
|
-
grouped_routes(klass).transform_values { |list| list.first(MAX_ITEMS).map { |
|
|
105
|
+
grouped_routes(klass).transform_values { |list| list.first(MAX_ITEMS).map { |entry| route_data(entry) } }
|
|
117
106
|
end
|
|
118
107
|
|
|
119
108
|
def grouped_routes(klass)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
109
|
+
entries = controller_entries(klass.controller_path)
|
|
110
|
+
entries.group_by { |entry| entry.route.defaults[:action].to_s }.reject { |action, _| action.empty? }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def controller_entries(path)
|
|
114
|
+
RouteEntries.call(self.class.rails_routes)
|
|
115
|
+
.select { |entry| entry.route.defaults[:controller].to_s == path }
|
|
123
116
|
end
|
|
124
117
|
|
|
125
118
|
# Differenced against the controller's full action_methods set, not the
|
|
@@ -128,19 +121,17 @@ module Coatepec
|
|
|
128
121
|
# have every route whose action fell past the truncation point reported
|
|
129
122
|
# here as a false positive, in the field the README calls the tool's
|
|
130
123
|
# most actionable output. Bounded to MAX_ITEMS like every other
|
|
131
|
-
# collection in this payload; `
|
|
124
|
+
# collection in this payload; `routes_by_action` itself caps each route
|
|
132
125
|
# *list* but not its key count, so this is where that cap belongs.
|
|
133
126
|
def routes_without_action(klass, routes)
|
|
134
127
|
defined_actions = klass.action_methods.map(&:to_s)
|
|
135
128
|
(routes.keys - defined_actions).sort.first(MAX_ITEMS)
|
|
136
129
|
end
|
|
137
130
|
|
|
138
|
-
# path
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def route_data(route)
|
|
143
|
-
{ verb: route.verb.to_s, path: route.path.spec.to_s, route_name: route.name&.to_s }
|
|
131
|
+
# path and engine come off the entry, so they are byte-identical to rails_routes' values for the
|
|
132
|
+
# same route. An engine route's name is engine-local: reach it as `<mount name>.<route_name>_path`.
|
|
133
|
+
def route_data(entry)
|
|
134
|
+
{ verb: entry.route.verb.to_s, path: entry.path, route_name: entry.route.name&.to_s, engine: entry.engine }
|
|
144
135
|
end
|
|
145
136
|
|
|
146
137
|
# Isolated as a class method purely so unit tests can stub it without
|
|
@@ -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.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../coatepec"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module MCP
|
|
7
|
+
# Fills a tool's omitted inputs: call argument first, then .coatepec.yml, then the built-in.
|
|
8
|
+
module Defaults
|
|
9
|
+
BUILTIN = {
|
|
10
|
+
spec_run: { include_passing: false, include_stdout: "failures", timeout_seconds: 120 },
|
|
11
|
+
routes: { engines: Introspection::Routes::DEFAULT_ENGINES }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Re-reads the file each call: a few hundred bytes, and an edit then applies to the next call.
|
|
17
|
+
def resolve(tool, project_root, **given)
|
|
18
|
+
configured = ProjectConfig.new(project_root).defaults_for(tool)
|
|
19
|
+
BUILTIN.fetch(tool).to_h do |key, builtin|
|
|
20
|
+
[key, given[key].nil? ? configured.fetch(key, builtin) : given[key]]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -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
|