phlex-reactive 0.9.3 → 0.9.5

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +282 -2
  4. data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
  5. data/app/javascript/phlex/reactive/inspect.js +225 -0
  6. data/app/javascript/phlex/reactive/inspect.min.js +4 -0
  7. data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/reactive_controller.js +659 -10
  9. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  10. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
  11. data/lib/generators/phlex/reactive/claude/USAGE +15 -0
  12. data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
  13. data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
  14. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
  15. data/lib/phlex/reactive/authorization.rb +118 -0
  16. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
  17. data/lib/phlex/reactive/component/dsl.rb +70 -0
  18. data/lib/phlex/reactive/component/helpers.rb +348 -21
  19. data/lib/phlex/reactive/component/identity.rb +10 -1
  20. data/lib/phlex/reactive/component/lazy.rb +79 -0
  21. data/lib/phlex/reactive/component/registry.rb +7 -1
  22. data/lib/phlex/reactive/component.rb +1 -0
  23. data/lib/phlex/reactive/defer.rb +363 -0
  24. data/lib/phlex/reactive/deferred_render_job.rb +116 -0
  25. data/lib/phlex/reactive/doctor.rb +79 -11
  26. data/lib/phlex/reactive/engine.rb +16 -2
  27. data/lib/phlex/reactive/inspector/report.rb +140 -0
  28. data/lib/phlex/reactive/inspector.rb +253 -0
  29. data/lib/phlex/reactive/log_subscriber.rb +10 -0
  30. data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
  31. data/lib/phlex/reactive/mcp/runner.rb +24 -0
  32. data/lib/phlex/reactive/mcp/server.rb +47 -0
  33. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
  34. data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
  35. data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
  36. data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
  37. data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
  38. data/lib/phlex/reactive/mcp.rb +58 -0
  39. data/lib/phlex/reactive/reply.rb +18 -0
  40. data/lib/phlex/reactive/response.rb +47 -2
  41. data/lib/phlex/reactive/streamable.rb +5 -1
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +253 -3
  44. data/lib/tasks/phlex_reactive.rake +28 -0
  45. 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