coatepec 0.6.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 +142 -0
- data/README.md +150 -25
- data/ROADMAP.md +163 -0
- data/lib/coatepec/introspection/bounded_options.rb +22 -0
- data/lib/coatepec/introspection/controller.rb +242 -0
- 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 +107 -79
- data/lib/coatepec/mcp.rb +4 -3
- 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 +7 -1
- data/lib/coatepec/worker_manager.rb +12 -6
- data/lib/coatepec.rb +6 -0
- metadata +18 -5
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "route_entries"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module Introspection
|
|
7
|
+
# Returns bounded ActionController class metadata for a single controller,
|
|
8
|
+
# for the rails_controller MCP tool. Pure reflection over an already-loaded,
|
|
9
|
+
# already-gated class -- no request dispatch, no action execution, and no
|
|
10
|
+
# evaluation of app-authored callback conditions.
|
|
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.
|
|
13
|
+
class Controller
|
|
14
|
+
NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
15
|
+
MAX_ITEMS = 200
|
|
16
|
+
|
|
17
|
+
# AbstractController::Callbacks::ActionFilter#match? reads exactly two
|
|
18
|
+
# things off the controller it is handed: `action_name`, and (Rails 7.1+)
|
|
19
|
+
# `raise_on_missing_callback_actions`. That second one must be false --
|
|
20
|
+
# when true, match? raises ActionNotFound for any action named in `only:`
|
|
21
|
+
# that the controller doesn't define, which is precisely the drift this
|
|
22
|
+
# tool exists to *report*, so it must never raise here. This Struct is
|
|
23
|
+
# the entire controller surface match? touches; nothing on it can
|
|
24
|
+
# execute application code.
|
|
25
|
+
CallbackProbe = Struct.new(:action_name, :raise_on_missing_callback_actions)
|
|
26
|
+
|
|
27
|
+
def initialize(name)
|
|
28
|
+
@name = name
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
validate_name!
|
|
33
|
+
klass = resolve!
|
|
34
|
+
validate_action_controller!(klass)
|
|
35
|
+
build_metadata(klass)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_metadata(klass)
|
|
41
|
+
actions = action_names(klass)
|
|
42
|
+
routes = routes_by_action(klass)
|
|
43
|
+
{
|
|
44
|
+
name: klass.name, controller_path: klass.controller_path,
|
|
45
|
+
actions: actions.map { |action| { name: action, routes: routes.fetch(action, []) } },
|
|
46
|
+
unroutable_actions: actions.reject { |action| routes.key?(action) },
|
|
47
|
+
routes_without_action: routes_without_action(klass, routes),
|
|
48
|
+
callbacks: callbacks_for(klass, actions), concerns: concerns_for(klass)
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_name!
|
|
53
|
+
return if @name.is_a?(String) && NAME_PATTERN.match?(@name)
|
|
54
|
+
|
|
55
|
+
raise Coatepec::Error.new(:invalid_controller_name, "#{@name.inspect} is not a valid constant name")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve!
|
|
59
|
+
::ActiveSupport::Inflector.safe_constantize(@name) ||
|
|
60
|
+
raise(Coatepec::Error.new(:controller_not_found, "#{@name} could not be resolved"))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Gate on Metal, not Base: an ActionController::API controller is not a
|
|
64
|
+
# Base descendant, so gating on Base would reject every API-only app.
|
|
65
|
+
def validate_action_controller!(klass)
|
|
66
|
+
unless defined?(::ActionController::Metal)
|
|
67
|
+
raise Coatepec::Error.new(:not_action_controller, "ActionController is not loaded in this app")
|
|
68
|
+
end
|
|
69
|
+
return if klass.is_a?(Class) && klass < ::ActionController::Metal
|
|
70
|
+
|
|
71
|
+
raise Coatepec::Error.new(:not_action_controller, "#{@name} is not an ActionController controller")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# action_methods is a Set of Strings. Sorted for stable output across
|
|
75
|
+
# runs. A public method contributed by a concern legitimately appears
|
|
76
|
+
# here -- Rails really would route to it, so surfacing it is the point,
|
|
77
|
+
# not a leak to filter out.
|
|
78
|
+
def action_names(klass)
|
|
79
|
+
klass.action_methods.to_a.map(&:to_s).sort.first(MAX_ITEMS)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Slice the ancestor chain at the first ActionController::* class in it:
|
|
83
|
+
# ActionController::Base for a normal controller, ActionController::API
|
|
84
|
+
# for an API-only one. Everything before that point was inserted by the
|
|
85
|
+
# app, so this needs no denylist of the ~60 framework modules below it.
|
|
86
|
+
# Anonymous modules have a nil name and are dropped.
|
|
87
|
+
def concerns_for(klass)
|
|
88
|
+
base = framework_base(klass)
|
|
89
|
+
klass.ancestors
|
|
90
|
+
.take_while { |mod| mod != base }
|
|
91
|
+
.reject { |mod| mod.is_a?(Class) }
|
|
92
|
+
.filter_map(&:name)
|
|
93
|
+
.first(MAX_ITEMS)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def framework_base(klass)
|
|
97
|
+
klass.ancestors.find do |mod|
|
|
98
|
+
mod.is_a?(Class) && mod.name.to_s.start_with?("ActionController::")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
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.
|
|
104
|
+
def routes_by_action(klass)
|
|
105
|
+
grouped_routes(klass).transform_values { |list| list.first(MAX_ITEMS).map { |entry| route_data(entry) } }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def grouped_routes(klass)
|
|
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 }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Differenced against the controller's full action_methods set, not the
|
|
119
|
+
# (possibly truncated-to-MAX_ITEMS) displayed `actions` list -- a
|
|
120
|
+
# controller with more than MAX_ITEMS action methods would otherwise
|
|
121
|
+
# have every route whose action fell past the truncation point reported
|
|
122
|
+
# here as a false positive, in the field the README calls the tool's
|
|
123
|
+
# most actionable output. Bounded to MAX_ITEMS like every other
|
|
124
|
+
# collection in this payload; `routes_by_action` itself caps each route
|
|
125
|
+
# *list* but not its key count, so this is where that cap belongs.
|
|
126
|
+
def routes_without_action(klass, routes)
|
|
127
|
+
defined_actions = klass.action_methods.map(&:to_s)
|
|
128
|
+
(routes.keys - defined_actions).sort.first(MAX_ITEMS)
|
|
129
|
+
end
|
|
130
|
+
|
|
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 }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Isolated as a class method purely so unit tests can stub it without
|
|
138
|
+
# booting Rails -- Rails.application.routes.routes is otherwise only
|
|
139
|
+
# reachable with a real, booted application. Mirrors
|
|
140
|
+
# Introspection::Routes.rails_routes.
|
|
141
|
+
# rubocop:disable Lint/IneffectiveAccessModifier
|
|
142
|
+
def self.rails_routes
|
|
143
|
+
Rails.application.routes.routes
|
|
144
|
+
end
|
|
145
|
+
# rubocop:enable Lint/IneffectiveAccessModifier
|
|
146
|
+
|
|
147
|
+
# Gated on Metal, not Base/API, so a bare ActionController::Metal
|
|
148
|
+
# subclass -- which passes validate_action_controller!'s class gate but
|
|
149
|
+
# does not include AbstractController::Callbacks, unlike Base and API --
|
|
150
|
+
# gets an empty callback list instead of a NoMethodError.
|
|
151
|
+
def callbacks_for(klass, actions)
|
|
152
|
+
return [] unless klass.respond_to?(:_process_action_callbacks)
|
|
153
|
+
|
|
154
|
+
klass._process_action_callbacks.first(MAX_ITEMS).map do |callback|
|
|
155
|
+
conditions_for(callback, actions)
|
|
156
|
+
.merge(kind: callback.kind.to_s, filter: filter_description(callback.filter))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# only:/except: do not survive as readable options. Rails compiles both
|
|
161
|
+
# into an ActionFilter and distinguishes them purely by *placement*: the
|
|
162
|
+
# `only:` filter lands in the callback's @if chain, the `except:` one in
|
|
163
|
+
# its @unless chain. Intent is therefore recovered from which chain the
|
|
164
|
+
# object sits in, not from the object itself.
|
|
165
|
+
#
|
|
166
|
+
# Reaching those chains needs instance_variable_get: Callback exposes
|
|
167
|
+
# `kind` and `filter` publicly but has no reader for @if/@unless. That
|
|
168
|
+
# single private read is unavoidable; having taken it, the action set is
|
|
169
|
+
# then read through ActionFilter's *public* match? rather than a second
|
|
170
|
+
# private read of its @actions, so this keeps working if Rails changes
|
|
171
|
+
# how ActionFilter stores them.
|
|
172
|
+
def conditions_for(callback, actions)
|
|
173
|
+
ifs = Array(callback.instance_variable_get(:@if))
|
|
174
|
+
unlesses = Array(callback.instance_variable_get(:@unless))
|
|
175
|
+
{
|
|
176
|
+
only: matched_actions(ifs, actions, :all?),
|
|
177
|
+
except: matched_actions(unlesses, actions, :any?),
|
|
178
|
+
if: plain_conditions(ifs),
|
|
179
|
+
unless: plain_conditions(unlesses)
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# nil (not []) when there is no ActionFilter at all: "this callback is
|
|
184
|
+
# unrestricted" and "this callback is restricted to no actions" are
|
|
185
|
+
# different facts and must not serialize identically.
|
|
186
|
+
#
|
|
187
|
+
# A chain routinely carries *more than one* ActionFilter: skip_callback
|
|
188
|
+
# (ActiveSupport::Callbacks::Callback#merge_conditional_options)
|
|
189
|
+
# concatenates a skip's normalized only:/except: onto the callback's
|
|
190
|
+
# existing @if/@unless chain rather than replacing it, so any
|
|
191
|
+
# `skip_before_action ..., only:`/`except:` leaves two ActionFilters
|
|
192
|
+
# behind. A callback only runs when *every* @if condition holds and
|
|
193
|
+
# *none* of its @unless conditions hold (ActiveSupport::Callbacks'
|
|
194
|
+
# run_callbacks ANDs @if and ANDs the negation of each @unless), so:
|
|
195
|
+
# only: is every @if ActionFilter's matches intersected (combinator
|
|
196
|
+
# :all? -- all must hold for the action to run the callback), and
|
|
197
|
+
# except: is every @unless ActionFilter's matches unioned (combinator
|
|
198
|
+
# :any? -- any one holding is enough to skip it). Keeping only the
|
|
199
|
+
# first ActionFilter in the chain (as a naive `find` would) silently
|
|
200
|
+
# drops every skip layered on top of it, which always biases toward
|
|
201
|
+
# over-reporting protection -- exactly backwards for a tool whose job is
|
|
202
|
+
# to answer "is this action protected?".
|
|
203
|
+
def matched_actions(conditions, actions, combinator)
|
|
204
|
+
filters = conditions.select { |condition| action_filter?(condition) }
|
|
205
|
+
return nil if filters.empty?
|
|
206
|
+
|
|
207
|
+
actions.select do |action|
|
|
208
|
+
filters.public_send(combinator) { |filter| filter.match?(CallbackProbe.new(action, false)) }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def action_filter?(condition)
|
|
213
|
+
defined?(::AbstractController::Callbacks::ActionFilter) &&
|
|
214
|
+
condition.is_a?(::AbstractController::Callbacks::ActionFilter)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The conditions that are *not* only:/except: -- a real `if:`/`unless:`.
|
|
218
|
+
# A Symbol is a method reference and safe to name; a Proc must never be
|
|
219
|
+
# serialized (Proc#to_s leaks the app's absolute source path).
|
|
220
|
+
def plain_conditions(conditions)
|
|
221
|
+
conditions.reject { |condition| action_filter?(condition) }
|
|
222
|
+
.map { |condition| filter_description(condition) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A Symbol filter (the method-reference form, e.g.
|
|
226
|
+
# `before_action :require_login`) is a method reference and safe to
|
|
227
|
+
# report by name; a Proc must never be serialized, because Proc#to_s
|
|
228
|
+
# leaks the app's absolute source path, so it is reduced to "(block)"
|
|
229
|
+
# instead. Introspection::SafeOptions guards the same class of leak
|
|
230
|
+
# elsewhere, by silently dropping Procs from an options hash rather than
|
|
231
|
+
# substituting a placeholder -- a different mechanism for the same rule.
|
|
232
|
+
def filter_description(filter)
|
|
233
|
+
case filter
|
|
234
|
+
when Symbol then filter.to_s
|
|
235
|
+
when Proc then "(block)"
|
|
236
|
+
else filter.class.name || "(anonymous filter class)"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
# rubocop:enable Metrics/ClassLength
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -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
|