Class: Phlex::Reactive::Doctor
- Inherits:
-
Object
- Object
- Phlex::Reactive::Doctor
- Defined in:
- lib/phlex/reactive/doctor.rb
Overview
Validates a phlex-reactive install and reports ✓/✗/? per check with a fix for each failure (issue #106). Five closed issues (#3 boot/eager-load, #26 route shadowing, #42 lost request, #48 unregistered controller, #57 importmap 404) were pure integration papercuts that only surfaced AFTER something already broke. The doctor turns "nothing happens, why?" into an actionable checklist you run before/after setup:
bin/rails phlex_reactive:doctor
Every check is a small object answering [status, message, fix]. It is READ-ONLY — it never mounts a component, mutates state, or touches the default-deny boundary; the worst it does is a throwaway sign→verify round trip and (for the component checks) iterate the loaded Streamable registry.
Defined Under Namespace
Classes: Check
Constant Summary collapse
- GLYPHS =
Glyphs are plain ASCII-safe Unicode with NO ANSI color, so CI/log capture reads cleanly (issue #106 acceptance: stable plain-text output).
{ ok: "✓", fail: "✗", unknown: "?" }.freeze
Class Method Summary collapse
- .actions_controller ⇒ Object
-
.run(io: $stdout) ⇒ Object
The entrypoint the rake task calls: eager-load so the component registry is populated, print the report, and return TRUE when nothing failed (an advisory
?doesn't count) so a caller can gate its exit code on it.
Instance Method Summary collapse
-
#action_check(components) ⇒ Object
Every declared
action :namemust have a public instance method (mirrors the endpoint's public_send dispatch — a missing one 500s at click). -
#authorization_check(components) ⇒ Object
ADVISORY (issue #168): the presence-side static heuristic complementing the runtime verify_authorized guard.
-
#base_controller_check ⇒ Object
Does Phlex::Reactive.base_controller_name constantize (issue #48-adjacent)?.
- #build_checks ⇒ Object
-
#checks ⇒ Object
Run every check and return the ordered list of Check objects, memoized so report + failures? share one pass.
-
#csrf_check ⇒ Object
ADVISORY only (issue #106): grep ERB layouts AND Phlex layout files for a csrf_meta_tags reference.
-
#defer_route_check ⇒ Object
Same shadow class for the defer endpoint (issue #165): a shadowed defer route makes every reply.defer / reactive_lazy fetch 404 — the pending marker clears into data-reactive-error="defer" client-side, but the root cause is invisible without this check.
-
#failures? ⇒ Boolean
True when any check FAILED (a hard ✗).
-
#id_check(components) ⇒ Object
Flag a class that would raise NotImplementedError in #id at render: it inherits Streamable's default #id AND is NOT record-backed.
-
#path_check(path, name, setting) ⇒ Object
Shared body for the two endpoint-route checks: both POST to the gem's ActionsController, so action_route_ok? answers for either path.
-
#render_check(check) ⇒ Object
One check as "✓/✗/? message" plus an indented "→ fix" when it isn't ok.
-
#report ⇒ Object
The full plain-text report: a line per check, plus an indented fix under each non-passing one.
-
#route_check ⇒ Object
Does POST <action_path> resolve to the gem's ActionsController? A host catch-all route shadows it otherwise (issue #26).
-
#stimulus_check ⇒ Object
Is the generic
reactivecontroller registered in a Stimulus entrypoint (issue #48)? Grep the candidate entrypoints for the register line; when importmap is present, additionally verify the pin resolves. -
#verifier_check ⇒ Object
A throwaway sign→verify round trip proves the verifier is configured and the key round-trips (a bad secret_key_base or a purpose mismatch fails).
Class Method Details
.actions_controller ⇒ Object
235 236 237 |
# File 'lib/phlex/reactive/doctor.rb', line 235 def self.actions_controller "phlex/reactive/actions" end |
.run(io: $stdout) ⇒ Object
The entrypoint the rake task calls: eager-load so the component registry
is populated, print the report, and return TRUE when nothing failed (an
advisory ? doesn't count) so a caller can gate its exit code on it.
(Not a predicate name — this is the imperative "run + report" action that
happens to return a success boolean; io is the conventional stream name.)
47 48 49 50 51 52 |
# File 'lib/phlex/reactive/doctor.rb', line 47 def self.run(io: $stdout) # rubocop:disable Naming/PredicateMethod,Naming/MethodParameterName ::Rails.application.eager_load! if defined?(::Rails) && ::Rails.application doctor = new io.puts(doctor.report) !doctor.failures? end |
Instance Method Details
#action_check(components) ⇒ Object
Every declared action :name must have a public instance method (mirrors
the endpoint's public_send dispatch — a missing one 500s at click).
177 178 179 180 181 182 183 |
# File 'lib/phlex/reactive/doctor.rb', line 177 def action_check(components) missing = components.flat_map { missing_action_methods(it) } return Check.new(:ok, "every declared action has a public method", name: :actions) if missing.empty? Check.new(:fail, "declared actions with no matching public method: #{missing.join(", ")}", name: :actions, fix: "Define a public method for each, or remove the `action :name` declaration.") end |
#authorization_check(components) ⇒ Object
ADVISORY (issue #168): the presence-side static heuristic complementing the runtime verify_authorized guard. Flags a non-skipped action whose component defines NONE of Phlex::Reactive.authorization_methods anywhere in its ancestry AND whose body (Prism-scanned via the Inspector) makes no authorization / mark_authorized! call. It is :unknown, NEVER a hard fail: a helper may authorize indirectly, so this is a hint, not a verdict.
204 205 206 207 208 209 210 211 212 213 |
# File 'lib/phlex/reactive/doctor.rb', line 204 def (components) suspects = components.flat_map { (it) } return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty? Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}", name: :authorization, fix: "This is a heuristic (a helper may authorize indirectly). If each is intentional, " \ "confirm it authorizes; if an action is genuinely public, declare " \ "`skip_verify_authorized`. See the Debugging & tooling docs page.") end |
#base_controller_check ⇒ Object
Does Phlex::Reactive.base_controller_name constantize (issue #48-adjacent)?
164 165 166 167 168 169 170 171 172 173 |
# File 'lib/phlex/reactive/doctor.rb', line 164 def base_controller_check name = Phlex::Reactive.base_controller_name klass = Phlex::Reactive.base_controller Check.new(:ok, "base_controller_name #{name} constantizes to #{klass}", name: :base_controller) rescue => e # rubocop:disable Style/RescueStandardError Check.new(:fail, "base_controller_name #{Phlex::Reactive.base_controller_name.inspect} " \ "does not constantize (#{e.class})", name: :base_controller, fix: "Set Phlex::Reactive.base_controller_name to a controller that exists " \ "(e.g. \"ApplicationController\").") end |
#build_checks ⇒ Object
68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/phlex/reactive/doctor.rb', line 68 def build_checks components = registered_components [ route_check, defer_route_check, stimulus_check, csrf_check, verifier_check, base_controller_check, action_check(components), id_check(components), (components) ] end |
#checks ⇒ Object
Run every check and return the ordered list of Check objects, memoized so report + failures? share one pass. The caller (or Doctor.run) is responsible for eager_load! so component classes are in the registry — the component checks are empty otherwise.
58 59 60 |
# File 'lib/phlex/reactive/doctor.rb', line 58 def checks @checks ||= build_checks end |
#csrf_check ⇒ Object
ADVISORY only (issue #106): grep ERB layouts AND Phlex layout files for a csrf_meta_tags reference. A hard fail would false-flag Phlex-layout apps (this gem's core audience), so a miss is :unknown, never :fail.
133 134 135 136 137 138 139 140 141 142 |
# File 'lib/phlex/reactive/doctor.rb', line 133 def csrf_check if Check.new(:ok, "csrf_meta_tags found in a layout", name: :csrf) else Check.new(:unknown, "could not verify csrf_meta_tags in a layout", name: :csrf, fix: "Confirm your layout renders csrf_meta_tags (ERB: <%= csrf_meta_tags %>; " \ "Phlex: render Phlex::Rails::Helpers::CSRFMetaTags or emit the meta tags) — " \ "the client reads the CSRF token from <meta name=\"csrf-token\">.") end end |
#defer_route_check ⇒ Object
Same shadow class for the defer endpoint (issue #165): a shadowed defer route makes every reply.defer / reactive_lazy fetch 404 — the pending marker clears into data-reactive-error="defer" client-side, but the root cause is invisible without this check.
96 97 98 |
# File 'lib/phlex/reactive/doctor.rb', line 96 def defer_route_check path_check(Phlex::Reactive.defer_path, :defer_route, "Phlex::Reactive.defer_path") end |
#failures? ⇒ Boolean
True when any check FAILED (a hard ✗). Advisory ? lines are not
failures — a Phlex-layout app legitimately can't verify csrf that way.
64 65 66 |
# File 'lib/phlex/reactive/doctor.rb', line 64 def failures? checks.any?(&:fail?) end |
#id_check(components) ⇒ Object
Flag a class that would raise NotImplementedError in #id at render: it inherits Streamable's default #id AND is NOT record-backed. A record-backed class on the default is FINE — that default shipped in #81.
188 189 190 191 192 193 194 195 196 |
# File 'lib/phlex/reactive/doctor.rb', line 188 def id_check(components) offenders = components.select { default_id_without_record?(it) }.map(&:name) return Check.new(:ok, "every component resolves a stable #id", name: :ids) if offenders.empty? Check.new(:fail, "state-backed components with no #id (render raises NotImplementedError): " \ "#{offenders.join(", ")}", name: :ids, fix: "Add `def id = \"my-thing\"` to each — a state-backed component has no record to " \ "derive a default id from.") end |
#path_check(path, name, setting) ⇒ Object
Shared body for the two endpoint-route checks: both POST to the gem's ActionsController, so action_route_ok? answers for either path.
102 103 104 105 106 107 108 109 110 111 |
# File 'lib/phlex/reactive/doctor.rb', line 102 def path_check(path, name, setting) if Phlex::Reactive.action_route_ok?(path) Check.new(:ok, "POST #{path} routes to #{Doctor.actions_controller}", name: name) else Check.new(:fail, "POST #{path} does not resolve to #{Doctor.actions_controller}", name: name, fix: "A host catch-all route (match \"*path\", ...) likely shadows it. Exempt " \ "#{path.delete_prefix("/")} from the catch-all, or set #{setting} " \ "to an unshadowed path.") end end |
#render_check(check) ⇒ Object
One check as "✓/✗/? message" plus an indented "→ fix" when it isn't ok.
229 230 231 232 233 |
# File 'lib/phlex/reactive/doctor.rb', line 229 def render_check(check) line = "#{GLYPHS.fetch(check.status)} #{check.}" line += "\n → #{check.fix}" if check.fix && !check.ok? line end |
#report ⇒ Object
The full plain-text report: a line per check, plus an indented fix under each non-passing one. No ANSI color (clean CI/log capture).
219 220 221 222 223 224 225 226 |
# File 'lib/phlex/reactive/doctor.rb', line 219 def report lines = ["phlex-reactive doctor", ""] results = checks results.each { lines << render_check(it) } lines << "" lines << summary_line(results) lines.join("\n") end |
#route_check ⇒ Object
Does POST <action_path> resolve to the gem's ActionsController? A host catch-all route shadows it otherwise (issue #26). Reuses the shipped guard verbatim — it already handles routes-not-yet-drawn.
88 89 90 |
# File 'lib/phlex/reactive/doctor.rb', line 88 def route_check path_check(Phlex::Reactive.action_path, :route, "Phlex::Reactive.action_path") end |
#stimulus_check ⇒ Object
Is the generic reactive controller registered in a Stimulus entrypoint
(issue #48)? Grep the candidate entrypoints for the register line; when
importmap is present, additionally verify the pin resolves.
116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/phlex/reactive/doctor.rb', line 116 def stimulus_check entrypoint = stimulus_registration_files.find { registers_reactive?(it) } return stimulus_missing_check unless entrypoint if importmap_pin_broken? return Check.new(:fail, "reactive registered in #{relative(entrypoint)}, but the importmap " \ "pin for phlex/reactive/reactive_controller is missing", name: :stimulus, fix: "The engine auto-pins it; if you overrode config/importmap.rb, add:\n " \ "pin \"phlex/reactive/reactive_controller\"") end Check.new(:ok, "reactive controller registered in #{relative(entrypoint)}", name: :stimulus) end |
#verifier_check ⇒ Object
A throwaway sign→verify round trip proves the verifier is configured and the key round-trips (a bad secret_key_base or a purpose mismatch fails). verify legitimately stamps the version key "v" (issue #111), so we check the original entries survived rather than exact equality.
148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/phlex/reactive/doctor.rb', line 148 def verifier_check payload = { "c" => "Phlex::Reactive::Doctor", "probe" => true } roundtripped = Phlex::Reactive.verify(Phlex::Reactive.sign(payload)) if roundtripped && payload.all? { |k, v| roundtripped[k] == v } Check.new(:ok, "identity verifier signs and verifies", name: :verifier) else Check.new(:fail, "identity verifier did not round-trip a probe payload", name: :verifier, fix: "Check secret_key_base is set, or configure a dedicated " \ "Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(key).") end rescue => e # rubocop:disable Style/RescueStandardError Check.new(:fail, "identity verifier raised: #{e.}", name: :verifier, fix: "Set secret_key_base, or configure Phlex::Reactive.verifier explicitly.") end |