Class: Phlex::Reactive::Doctor

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Class Method Details

.actions_controllerObject



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 authorization_check(components)
  suspects = components.flat_map { unauthorized_action_labels(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_checkObject

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_checksObject



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),
    authorization_check(components)
  ]
end

#checksObject

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_checkObject

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 csrf_meta_referenced?
    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_checkObject

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.

Returns:

  • (Boolean)


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.message}"
  line += "\n    → #{check.fix}" if check.fix && !check.ok?
  line
end

#reportObject

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_checkObject

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_checkObject

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_checkObject

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.message}", name: :verifier,
    fix: "Set secret_key_base, or configure Phlex::Reactive.verifier explicitly.")
end