phlex-reactive 0.9.2 → 0.9.4
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/CHANGELOG.md +213 -0
- data/README.md +333 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +651 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/generators/phlex/reactive/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +240 -0
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- metadata +22 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
module Inspector
|
|
8
|
+
# Renders the Inspector's read-only inventory for the `phlex_reactive:actions`
|
|
9
|
+
# and `phlex_reactive:find` rake tasks (issue #168). Plain text by default,
|
|
10
|
+
# JSON when asked — the SAME no-ANSI posture as Doctor (clean CI/log
|
|
11
|
+
# capture). Reports NAMES/paths/schemas only, never tokens/secrets/state.
|
|
12
|
+
module Report
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# A plain-text table of every component × action, or a JSON array when
|
|
16
|
+
# `format` is :json. One row per action: component, action, params,
|
|
17
|
+
# file:line, authorization heuristic.
|
|
18
|
+
def actions(components, format: :text)
|
|
19
|
+
return actions_json(components) if format == :json
|
|
20
|
+
|
|
21
|
+
actions_text(components)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The find result: a ranked list header, then the TOP match in detail —
|
|
25
|
+
# each action's params, source location, authorization heuristic, and the
|
|
26
|
+
# full method-definition source. Empty message when nothing matched.
|
|
27
|
+
def find(matches, query)
|
|
28
|
+
return %(no component matched "#{query}") if matches.empty?
|
|
29
|
+
|
|
30
|
+
lines = [%(#{matches.size} match#{"es" unless matches.size == 1} for "#{query}":)]
|
|
31
|
+
matches.each { lines << " #{it.name}" }
|
|
32
|
+
lines << ""
|
|
33
|
+
lines.concat(detail_lines(matches.first))
|
|
34
|
+
lines.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -- plain text -------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def actions_text(components)
|
|
40
|
+
return "no reactive components found" if components.empty?
|
|
41
|
+
|
|
42
|
+
rows = components.flat_map { component_rows(it) }
|
|
43
|
+
render_table(%w[COMPONENT ACTION PARAMS FILE:LINE AUTH], rows)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def component_rows(info)
|
|
47
|
+
return [[info.name, "(no actions)", "", location_str(info.path), ""]] if info.actions.empty?
|
|
48
|
+
|
|
49
|
+
info.actions.map do
|
|
50
|
+
[info.name, it.name.to_s, params_str(it.params),
|
|
51
|
+
location_str(it.source_location), auth_str(it)]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A minimal fixed-column table: each column padded to its widest cell.
|
|
56
|
+
# No ANSI, no external dependency.
|
|
57
|
+
def render_table(headers, rows)
|
|
58
|
+
all = [headers] + rows
|
|
59
|
+
# Two nested blocks (column index + row) need distinct named params —
|
|
60
|
+
# `it` would collide, so name both.
|
|
61
|
+
widths = headers.each_index.map { |col| all.map { |row| row[col].to_s.length }.max } # rubocop:disable Style/ItBlockParameter
|
|
62
|
+
all.map { format_row(it, widths) }.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def format_row(row, widths)
|
|
66
|
+
row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# -- find detail ------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def detail_lines(info)
|
|
72
|
+
lines = ["#{info.name} (#{location_str(info.path)})"]
|
|
73
|
+
lines << " record: #{info.record_key}" if info.record_key
|
|
74
|
+
lines << " state: #{info.state_keys.join(", ")}" if info.state_keys.any?
|
|
75
|
+
lines << ""
|
|
76
|
+
info.actions.each { lines.concat(action_detail(it)) }
|
|
77
|
+
lines
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def action_detail(action)
|
|
81
|
+
header = " action :#{action.name}"
|
|
82
|
+
header += " params: #{params_str(action.params)}" unless action.params.empty?
|
|
83
|
+
header += " (#{location_str(action.source_location)}) auth: #{auth_str(action)}"
|
|
84
|
+
body = action.definition ? indent(action.definition) : " (method not defined)"
|
|
85
|
+
[header, body, ""]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def indent(text)
|
|
89
|
+
text.each_line.map { " #{it}" }.join.rstrip
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# -- json -------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def actions_json(components)
|
|
95
|
+
JSON.pretty_generate(components.map { component_hash(it) })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def component_hash(info)
|
|
99
|
+
{
|
|
100
|
+
component: info.name,
|
|
101
|
+
path: location_str(info.path),
|
|
102
|
+
record_key: info.record_key,
|
|
103
|
+
state_keys: info.state_keys,
|
|
104
|
+
actions: info.actions.map { action_hash(it) }
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def action_hash(action)
|
|
109
|
+
{
|
|
110
|
+
name: action.name,
|
|
111
|
+
params: action.params,
|
|
112
|
+
source_location: location_str(action.source_location),
|
|
113
|
+
authorization_call_detected: action.authorization_call_detected?
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# -- shared formatters ------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def params_str(params)
|
|
120
|
+
params.empty? ? "" : params.inspect
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# "file.rb:42", relative to Rails.root when possible. nil location (a
|
|
124
|
+
# declared-but-missing method, or an unlocatable const) prints "?".
|
|
125
|
+
def location_str(location)
|
|
126
|
+
return "?" unless location
|
|
127
|
+
|
|
128
|
+
file, line = location
|
|
129
|
+
file = file.delete_prefix("#{::Rails.root}/") if defined?(::Rails) && ::Rails.root
|
|
130
|
+
line ? "#{file}:#{line}" : file
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The authorization heuristic as a short label — advisory only.
|
|
134
|
+
def auth_str(action)
|
|
135
|
+
action.authorization_call_detected? ? "authorized*" : "unverified"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
# The ONE read-only introspection layer (issue #168), consumed by the rake
|
|
8
|
+
# tasks (`phlex_reactive:actions` / `find`), the MCP tools, and Doctor. It
|
|
9
|
+
# discovers every constant-backed reactive component from the loaded
|
|
10
|
+
# Streamable registry and, per declared action, its param schema, source
|
|
11
|
+
# location, full method-definition source (extracted with Prism), and a
|
|
12
|
+
# heuristic authorization status.
|
|
13
|
+
#
|
|
14
|
+
# It reports NAMES, PATHS, and declared SCHEMAS only — never tokens, secrets,
|
|
15
|
+
# or runtime state (the instrumentation privacy contract extended to
|
|
16
|
+
# tooling). Like Doctor, it is READ-ONLY: it never mounts a component,
|
|
17
|
+
# mutates state, or touches the default-deny boundary.
|
|
18
|
+
#
|
|
19
|
+
# Phlex::Reactive::Inspector.components # => [ComponentInfo]
|
|
20
|
+
# Phlex::Reactive::Inspector.find(query) # => ranked [ComponentInfo]
|
|
21
|
+
module Inspector
|
|
22
|
+
# One declared action's introspection.
|
|
23
|
+
#
|
|
24
|
+
# * source_location — klass.instance_method(name).source_location, or nil
|
|
25
|
+
# when the action is declared but the method is
|
|
26
|
+
# missing (the endpoint would 500 on public_send).
|
|
27
|
+
# * definition — the full `def ... end` source, extracted with Prism.
|
|
28
|
+
# nil when the method is missing or its file is
|
|
29
|
+
# unreadable/unparseable (degrade, never raise).
|
|
30
|
+
# * authorization_call_detected? — a HEURISTIC: true when the Prism scan
|
|
31
|
+
# of the definition finds a call to any configured
|
|
32
|
+
# authorization method or mark_authorized!. A helper
|
|
33
|
+
# may authorize indirectly, so this is advisory ONLY.
|
|
34
|
+
ActionInfo = Data.define(:name, :params, :source_location, :definition) do
|
|
35
|
+
# Was any authorization method (or mark_authorized!) called directly in
|
|
36
|
+
# the method body? Heuristic — a Prism scan of `definition`. False when
|
|
37
|
+
# the definition is unavailable.
|
|
38
|
+
def authorization_call_detected?
|
|
39
|
+
return false unless definition
|
|
40
|
+
|
|
41
|
+
Inspector.authorization_call?(definition)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One reactive component's introspection.
|
|
46
|
+
#
|
|
47
|
+
# * path — Object.const_source_location(name) => [file, line].
|
|
48
|
+
# * record_key — the declared reactive_record key, or nil.
|
|
49
|
+
# * state_keys — the declared reactive_state keys.
|
|
50
|
+
# * actions — [ActionInfo], one per declared action.
|
|
51
|
+
ComponentInfo = Data.define(:klass, :name, :path, :record_key, :state_keys, :actions)
|
|
52
|
+
|
|
53
|
+
# The authorization method names the heuristic looks for. Reads
|
|
54
|
+
# Phlex::Reactive.authorization_methods when configured (issue #168 phase 2);
|
|
55
|
+
# otherwise the common set (Pundit/CanCanCan/ActionPolicy-style). The manual
|
|
56
|
+
# escape hatch `mark_authorized!` always counts.
|
|
57
|
+
DEFAULT_AUTHORIZATION_METHODS = %i[authorize! authorize allowed_to?].freeze
|
|
58
|
+
MANUAL_AUTHORIZATION_MARK = :mark_authorized!
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# Every constant-backed reactive component, as [ComponentInfo]. Call
|
|
62
|
+
# Rails.application.eager_load! first (the caller / rake task does) so
|
|
63
|
+
# every app component is in the Streamable registry.
|
|
64
|
+
def components
|
|
65
|
+
reactive_component_classes.map { component_info(it) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Ranked fuzzy matches for `query` (issue #168). Scored on BOTH the
|
|
69
|
+
# demodulized and the full namespaced name, best of the two:
|
|
70
|
+
# exact > prefix > substring > subsequence, case-insensitive. Returns []
|
|
71
|
+
# on no match. No dependency — a ~1-screen scorer.
|
|
72
|
+
def find(query)
|
|
73
|
+
q = query.to_s.downcase
|
|
74
|
+
return [] if q.empty?
|
|
75
|
+
|
|
76
|
+
reactive_component_classes
|
|
77
|
+
.filter_map { score_component(it, q) }
|
|
78
|
+
.sort_by { |score, name, _klass| [-score, name] }
|
|
79
|
+
.map { |_score, _name, klass| component_info(klass) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# True when a component class is a real, CONSTANT-RESOLVABLE reactive
|
|
83
|
+
# component — its own name round-trips through safe_constantize (exactly
|
|
84
|
+
# how ActionsController#resolve_component rebuilds it from the token). This
|
|
85
|
+
# also excludes anonymous classes (name nil) and fixtures that fake
|
|
86
|
+
# `def self.name` without a matching constant. Moved here from Doctor
|
|
87
|
+
# (issue #168); Doctor delegates.
|
|
88
|
+
def constant_backed_component?(klass)
|
|
89
|
+
reactive_component?(klass) && klass.name && klass.name.safe_constantize.equal?(klass)
|
|
90
|
+
rescue StandardError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reactive_component?(klass)
|
|
95
|
+
klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
|
|
96
|
+
rescue StandardError
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Does the method-definition source `definition` call an authorization
|
|
101
|
+
# method or mark_authorized! anywhere? A Prism scan for a call node whose
|
|
102
|
+
# method name is in the configured set. Heuristic; degrades to false on
|
|
103
|
+
# an unparseable snippet.
|
|
104
|
+
def authorization_call?(definition)
|
|
105
|
+
result = Prism.parse(definition)
|
|
106
|
+
return false unless result.success?
|
|
107
|
+
|
|
108
|
+
names = authorization_method_names
|
|
109
|
+
call_names(result.value).any? { names.include?(it) }
|
|
110
|
+
rescue StandardError
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The configured authorization method names plus the manual mark, as a
|
|
115
|
+
# Set of symbols. Reads Phlex::Reactive.authorization_methods when it
|
|
116
|
+
# exists (phase 2), else the default set. Guarded: a misconfigured
|
|
117
|
+
# authorization_methods (nil, or a single symbol) is coerced through
|
|
118
|
+
# Array() rather than raising `.map` on nil — which the caller's rescue
|
|
119
|
+
# would swallow, silently disabling the heuristic instead of surfacing it.
|
|
120
|
+
def authorization_method_names
|
|
121
|
+
configured =
|
|
122
|
+
if Phlex::Reactive.respond_to?(:authorization_methods)
|
|
123
|
+
Phlex::Reactive.authorization_methods || DEFAULT_AUTHORIZATION_METHODS
|
|
124
|
+
else
|
|
125
|
+
DEFAULT_AUTHORIZATION_METHODS
|
|
126
|
+
end
|
|
127
|
+
(Array(configured).map(&:to_sym) + [MANUAL_AUTHORIZATION_MARK]).to_set
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def reactive_component_classes
|
|
133
|
+
Phlex::Reactive::Streamable.registered_classes.select { constant_backed_component?(it) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def component_info(klass)
|
|
137
|
+
ComponentInfo.new(
|
|
138
|
+
klass:,
|
|
139
|
+
name: klass.name,
|
|
140
|
+
path: Object.const_source_location(klass.name),
|
|
141
|
+
record_key: (klass.reactive_record_key if klass.respond_to?(:reactive_record_key)),
|
|
142
|
+
state_keys: state_keys_for(klass),
|
|
143
|
+
actions: action_infos(klass)
|
|
144
|
+
)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
ComponentInfo.new(klass:, name: klass.name, path: nil, record_key: nil, state_keys: [], actions: [])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def state_keys_for(klass)
|
|
150
|
+
klass.respond_to?(:reactive_state_keys) ? klass.reactive_state_keys : []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def action_infos(klass)
|
|
154
|
+
klass.reactive_actions.values.map { action_info(klass, it) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def action_info(klass, action)
|
|
158
|
+
location = method_location(klass, action.name)
|
|
159
|
+
ActionInfo.new(
|
|
160
|
+
name: action.name,
|
|
161
|
+
params: action.params,
|
|
162
|
+
source_location: location,
|
|
163
|
+
definition: (extract_definition(location) if location)
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The method's [file, line], or nil when the declared method is missing
|
|
168
|
+
# (an undeclared/typo'd action — the endpoint would 500 on public_send).
|
|
169
|
+
def method_location(klass, name)
|
|
170
|
+
return nil unless klass.method_defined?(name) || klass.private_method_defined?(name)
|
|
171
|
+
|
|
172
|
+
klass.instance_method(name).source_location
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The full `def ... end` source at [file, line], via Prism. Finds the def
|
|
178
|
+
# node whose location spans `line` and returns its slice. nil when the
|
|
179
|
+
# file is unreadable/unparseable or no def node covers the line — never
|
|
180
|
+
# raises.
|
|
181
|
+
def extract_definition(location)
|
|
182
|
+
file, line = location
|
|
183
|
+
return nil unless file && line && File.readable?(file)
|
|
184
|
+
|
|
185
|
+
result = Prism.parse_file(file)
|
|
186
|
+
return nil unless result.success?
|
|
187
|
+
|
|
188
|
+
node = def_node_at(result.value, line)
|
|
189
|
+
node&.slice
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Depth-first search for the DefNode whose source line == `line`.
|
|
195
|
+
def def_node_at(node, line)
|
|
196
|
+
return node if def_node_on_line?(node, line)
|
|
197
|
+
|
|
198
|
+
node.compact_child_nodes.each do
|
|
199
|
+
found = def_node_at(it, line)
|
|
200
|
+
return found if found
|
|
201
|
+
end
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def def_node_on_line?(node, line)
|
|
206
|
+
node.is_a?(Prism::DefNode) && node.location.start_line == line
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Every method name called anywhere under `node` (a Prism CallNode's
|
|
210
|
+
# name), depth-first. Used by the authorization heuristic.
|
|
211
|
+
def call_names(node, acc = [])
|
|
212
|
+
acc << node.name if node.is_a?(Prism::CallNode)
|
|
213
|
+
node.compact_child_nodes.each { call_names(it, acc) }
|
|
214
|
+
acc
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# -- fuzzy scoring ----------------------------------------------------
|
|
218
|
+
|
|
219
|
+
# The best match score of the demodulized and full names, or nil if
|
|
220
|
+
# neither matches. Higher is better; ties broken by name in .find.
|
|
221
|
+
def score_component(klass, query)
|
|
222
|
+
full = klass.name.downcase
|
|
223
|
+
demodulized = klass.name.split("::").last.downcase
|
|
224
|
+
score = [match_score(demodulized, query), match_score(full, query)].compact.max
|
|
225
|
+
return nil unless score
|
|
226
|
+
|
|
227
|
+
[score, klass.name, klass]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# exact 4 > prefix 3 > substring 2 > subsequence 1 > no match nil.
|
|
231
|
+
def match_score(candidate, query)
|
|
232
|
+
return 4 if candidate == query
|
|
233
|
+
return 3 if candidate.start_with?(query)
|
|
234
|
+
return 2 if candidate.include?(query)
|
|
235
|
+
return 1 if subsequence?(candidate, query)
|
|
236
|
+
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Are the characters of `query` present in `candidate` in order (gaps
|
|
241
|
+
# allowed)? The loosest fuzzy tier.
|
|
242
|
+
def subsequence?(candidate, query)
|
|
243
|
+
i = 0
|
|
244
|
+
candidate.each_char do
|
|
245
|
+
i += 1 if it == query[i]
|
|
246
|
+
return true if i == query.length
|
|
247
|
+
end
|
|
248
|
+
query.empty?
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -54,6 +54,16 @@ module Phlex
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# The defer endpoint / render leg (issue #165). Like #action, an
|
|
58
|
+
# :invalid_token event carries no TRUSTED component name — omit it.
|
|
59
|
+
def defer(event)
|
|
60
|
+
return unless logger.debug?
|
|
61
|
+
|
|
62
|
+
payload = event.payload
|
|
63
|
+
subject = [payload[:component], payload[:outcome]].compact.join(" ")
|
|
64
|
+
debug { "[reactive] defer #{subject} (#{round(event.duration)}ms)" }
|
|
65
|
+
end
|
|
66
|
+
|
|
57
67
|
private
|
|
58
68
|
|
|
59
69
|
def round(duration_ms)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
module MCP
|
|
8
|
+
# The base class every phlex-reactive MCP tool subclasses (issue #168),
|
|
9
|
+
# mirroring pgbus's BaseTool. It sets the read-only annotation contract ONCE
|
|
10
|
+
# and overrides annotations_value so subclasses inherit it — MCP::Tool.inherited
|
|
11
|
+
# resets @annotations_value to nil per subclass, so without this each tool
|
|
12
|
+
# would advertise no annotations.
|
|
13
|
+
#
|
|
14
|
+
# Every tool is READ-ONLY and NON-DESTRUCTIVE: it reads the loaded registry
|
|
15
|
+
# (via Inspector / Doctor) and reports NAMES, PATHS, and declared SCHEMAS
|
|
16
|
+
# only — never a token, secret, or runtime state (the instrumentation
|
|
17
|
+
# privacy contract extended to tooling). There is deliberately NO
|
|
18
|
+
# arbitrary-query tool.
|
|
19
|
+
class BaseTool < ::MCP::Tool
|
|
20
|
+
annotations(
|
|
21
|
+
read_only_hint: true,
|
|
22
|
+
destructive_hint: false,
|
|
23
|
+
idempotent_hint: true,
|
|
24
|
+
open_world_hint: false
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Inherit the base annotations into every subclass (MCP::Tool.inherited
|
|
29
|
+
# nils @annotations_value, so fall through to the superclass's).
|
|
30
|
+
def annotations_value
|
|
31
|
+
super || (superclass.respond_to?(:annotations_value) ? superclass.annotations_value : nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A single text-content response carrying JSON.generate(value). The one
|
|
35
|
+
# place tool output is serialized — pretty-printed for a human reading
|
|
36
|
+
# the transcript, still valid JSON for the client.
|
|
37
|
+
def json_response(value)
|
|
38
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(value) }])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# An error response (the tool ran but has nothing to return — e.g. a
|
|
42
|
+
# find with no match handled by the tool, or a bad filter).
|
|
43
|
+
def error_response(message)
|
|
44
|
+
::MCP::Tool::Response.new([{ type: "text", text: message }], error: true)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Populate the Streamable registry the Inspector/Doctor read — the
|
|
48
|
+
# tools call this before introspecting so every app component is
|
|
49
|
+
# loaded. Guarded for a non-Rails / not-yet-booted context.
|
|
50
|
+
def eager_load_app!
|
|
51
|
+
::Rails.application.eager_load! if defined?(::Rails) && ::Rails.application
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module MCP
|
|
6
|
+
# Drives the read-only MCP server over stdio (issue #168). Invoked by the
|
|
7
|
+
# `phlex_reactive:mcp` rake task after MCP.load!.
|
|
8
|
+
#
|
|
9
|
+
# KNOWN CONSTRAINT (same as pgbus): stdio MCP requires a CLEAN stdout — the
|
|
10
|
+
# JSON-RPC frames are the ONLY thing that may be written there. An
|
|
11
|
+
# initializer that `puts` (or any library that writes to $stdout) breaks the
|
|
12
|
+
# transport. Log to $stderr or Rails.logger, never $stdout.
|
|
13
|
+
module Runner
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
server = Server.build
|
|
18
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(server)
|
|
19
|
+
transport.open
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module MCP
|
|
6
|
+
# Builds the read-only MCP::Server (issue #168). The tool array is FIXED and
|
|
7
|
+
# FROZEN — there is deliberately no arbitrary-query or mutation tool; every
|
|
8
|
+
# tool reads the loaded registry and reports names/paths/schemas only.
|
|
9
|
+
module Server
|
|
10
|
+
TOOLS = [
|
|
11
|
+
Tools::DoctorTool,
|
|
12
|
+
Tools::ComponentsTool,
|
|
13
|
+
Tools::ActionsTool,
|
|
14
|
+
Tools::FindTool,
|
|
15
|
+
Tools::ConfigTool
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
INSTRUCTIONS = <<~TEXT
|
|
19
|
+
Read-only diagnostic tools for a phlex-reactive install. Use them to
|
|
20
|
+
answer "what reactive components/actions exist, where are they defined,
|
|
21
|
+
is each authorized, and is the install wired correctly?" without grepping.
|
|
22
|
+
|
|
23
|
+
- phlex_reactive_doctor: validate the install (route, Stimulus, verifier, …).
|
|
24
|
+
- phlex_reactive_components: the component inventory (names, paths, keys).
|
|
25
|
+
- phlex_reactive_actions: per-action detail (params, source, authorization).
|
|
26
|
+
- phlex_reactive_find: fuzzy search + method-definition source.
|
|
27
|
+
- phlex_reactive_config: a redacted config summary (never secrets/tokens).
|
|
28
|
+
|
|
29
|
+
For reference documentation (not live-app introspection) see the hosted
|
|
30
|
+
docs MCP at https://phlex-reactive.zoolutions.llc/llms.txt.
|
|
31
|
+
TEXT
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def build
|
|
36
|
+
::MCP::Server.new(
|
|
37
|
+
name: "phlex-reactive",
|
|
38
|
+
title: "phlex-reactive diagnostics",
|
|
39
|
+
version: Phlex::Reactive::VERSION,
|
|
40
|
+
instructions: INSTRUCTIONS,
|
|
41
|
+
tools: TOOLS.dup
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module MCP
|
|
6
|
+
module Tools
|
|
7
|
+
# phlex_reactive_actions — the full action inventory: per action, the
|
|
8
|
+
# declared param schema, source location, and the authorization heuristic.
|
|
9
|
+
# Optional `component:` filter narrows to one component.
|
|
10
|
+
class ActionsTool < BaseTool
|
|
11
|
+
tool_name "phlex_reactive_actions"
|
|
12
|
+
title "phlex-reactive actions"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
The full reactive action inventory: for every component, each declared
|
|
15
|
+
action with its param schema, source file:line, and a heuristic
|
|
16
|
+
authorization status (whether an authorization method or
|
|
17
|
+
mark_authorized! call was detected in the body — advisory only, since a
|
|
18
|
+
helper may authorize indirectly). Pass `component:` to scope to one
|
|
19
|
+
component (exact name). Read-only — schemas and paths, never runtime
|
|
20
|
+
values.
|
|
21
|
+
DESC
|
|
22
|
+
|
|
23
|
+
input_schema(
|
|
24
|
+
properties: {
|
|
25
|
+
component: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Exact component class name to scope to (e.g. \"CounterComponent\"). Omit for all."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: []
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def self.call(component: nil, server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
34
|
+
eager_load_app!
|
|
35
|
+
infos = Phlex::Reactive::Inspector.components
|
|
36
|
+
infos = infos.select { it.name == component } if component
|
|
37
|
+
json_response(components: infos.map { Phlex::Reactive::Inspector::Report.component_hash(it) })
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module MCP
|
|
6
|
+
module Tools
|
|
7
|
+
# phlex_reactive_components — a summary of every constant-backed reactive
|
|
8
|
+
# component: name, source path, action count, record/state keys. The
|
|
9
|
+
# bird's-eye inventory; phlex_reactive_actions has the per-action detail.
|
|
10
|
+
class ComponentsTool < BaseTool
|
|
11
|
+
tool_name "phlex_reactive_components"
|
|
12
|
+
title "phlex-reactive components"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
List every reactive component in the app with its name, source
|
|
15
|
+
file:line, action count, and record/state keys. Use phlex_reactive_actions
|
|
16
|
+
for the full per-action detail, or phlex_reactive_find to search.
|
|
17
|
+
Read-only — names, paths, and declared keys only.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
input_schema(properties: {}, required: [])
|
|
21
|
+
|
|
22
|
+
def self.call(server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
+
eager_load_app!
|
|
24
|
+
components = Phlex::Reactive::Inspector.components.map do
|
|
25
|
+
{
|
|
26
|
+
name: it.name,
|
|
27
|
+
path: Phlex::Reactive::Inspector::Report.location_str(it.path),
|
|
28
|
+
action_count: it.actions.length,
|
|
29
|
+
record_key: it.record_key,
|
|
30
|
+
state_keys: it.state_keys
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
json_response(components: components)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|