Class: Ruact::Doctor

Inherits:
Object
  • Object
show all
Defined in:
lib/ruact/doctor.rb

Overview

Runs a suite of installation health checks and prints ✓/✗ per check. Extracted from the ruact:doctor Rake task for direct testability (FR27).

Constant Summary collapse

CHECKS =

rubocop:disable Metrics/ClassLength

%i[manifest vite controller layout streaming legacy_constant serialize_only flight_middleware].freeze
LEGACY_CONST =

Built via Array#join so the gem-CI name-propagation guard does not match these literals against itself (Story 5.1 review F4 — the doctor file participates in the guard with no exclusion).

%w[Rails Rsc].join
LEGACY_GEM =
%w[rails rsc].join("_")
LEGACY_CONSTANT_RE =
/(?<![A-Za-z_])(?:#{LEGACY_CONST}|#{LEGACY_GEM})(?![A-Za-z_])/
LEGACY_SCAN_GLOBS =
["config/initializers/**/*.rb", "app/**/*.rb"].freeze
RENAME_DOC_URL =
"https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed"
DESERIALIZE_SIGNALS =

--- Story 13.1: serialize-only invariant tripwire (FR97) ---------------

ruact EMITS React Flight (text/x-component) but must never DESERIALIZE externally-supplied Flight into live Ruby objects — that keeps it outside the React2Shell / CVE-2025-55182 class (a Flight-deserialization RCE). See the ADR addendum in docs/internal/decisions/server-functions-api.md.

The signal literals are assembled from fragments via Array#join so this file does NOT itself contain the matched strings (mirrors the LEGACY_CONST F4 lesson). doctor.rb is also excluded from the scan as defense in depth. Only structural inbound-deserialization signals are used; the raw text/x-component token is deliberately NOT a signal because the gem legitimately EMITS that media type (controller.rb / server.rb) — matching it would false-fail the current, invariant-holding tree.

[
  # a `Deserializer` constant reference (FlightDeserializer, Flight::Deserializer, …);
  # matched as a substring so both the joined and namespaced forms trip it
  /#{%w[Deser ializer].join}/,
  # methods that turn inbound Flight into Ruby objects
  /\b#{%w[deserialize flight].join('_')}\b/,
  /\b#{%w[from flight].join('_')}\b/,
  /\b#{%w[parse flight].join('_')}\b/,
  /\b#{%w[decode flight].join('_')}\b/,
  # React Flight reader entry points invoked from Ruby (NOT createFromFlightPayload,
  # which is the client/browser deserializing the server's own trusted payload)
  /\b#{%w[create From].join}(?:NodeStream|ReadableStream|Fetch)\b/
].freeze
DESERIALIZE_SIGNAL_RE =
Regexp.union(DESERIALIZE_SIGNALS)
ALLOW_FLIGHT_DESERIALIZATION =

A line carrying this annotation is a deliberate, reviewed deserializer and is treated as guarded (the check is a guard, not a blanket ban).

["# ruact:allow", "flight", "deserialization"].join("-")
DOCTOR_FILE =

Exclude THIS file by its exact path (not basename) — its comments contain the literal Deserializer example, so it must not match its own scan; but a differently-located future file also named doctor.rb must still be scanned (review finding R1 — basename exclusion was too broad).

File.expand_path(__FILE__)
SERIALIZE_ONLY_DOC =
"docs/internal/decisions/server-functions-api.md (serialize-only invariant, FR97)"
RESPONSE_TRANSFORMING_MIDDLEWARE =

Response-transforming middleware that can mutate/recompress a streamed text/x-component Flight body and break the wire contract (React-on-Rails ops lesson). Matched by class name so the check needs no hard dependency.

%w[Rack::Deflater].freeze
SUCCESS_STATUSES =

Statuses that do NOT fail the run. Anything else (including a malformed / future status) is treated as a failure (review finding R1).

%i[pass warn].freeze
SCHEMA_VERSION =

Story 15.3 (FR107) — version of the machine-readable ruact:doctor --json document (see #as_json). This is an EXPERIMENTAL / UNSTABLE contract: 0 signals the shape may change without a major version bump while the agent-facing introspection surface is iterated. Gate any parser on it. Distinct from ServerFunctions::Snapshot::VERSION_V2 (the internal codegen bridge version) — do not conflate the two.

0

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(serialize_only_root: File.join(Ruact.gem_path, "lib")) ⇒ Doctor

Returns a new instance of Doctor.

Parameters:

  • serialize_only_root (String) (defaults to: File.join(Ruact.gem_path, "lib"))

    directory whose **/*.rb is scanned for the serialize-only tripwire. Defaults to the gem's own lib/; injectable so specs can point it at a fixture tree.



78
79
80
# File 'lib/ruact/doctor.rb', line 78

def initialize(serialize_only_root: File.join(Ruact.gem_path, "lib"))
  @serialize_only_root = serialize_only_root
end

Class Method Details

.runObject

Runs all checks, prints results, returns true if none FAIL.



83
84
85
# File 'lib/ruact/doctor.rb', line 83

def self.run
  new.run
end

Instance Method Details

#as_jsonHash

Story 15.3 (FR107) — the machine-readable ruact:doctor --json document. Reuses the SAME #results tuples the human path prints (no double-run), so the JSON never disagrees with the ✓/⚠/✗ output. EXPERIMENTAL shape — gate parsers on schema_version (see SCHEMA_VERSION). Emits NO prose — the rake task prints ONLY this document in JSON mode.

Returns:

  • (Hash)

    { "schema_version" => Integer, "status" => "pass"|"fail", "checks" => [{ "name" =>, "status" =>, "message" =>, "remediation" => (String|nil) }] }.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/ruact/doctor.rb', line 127

def as_json
  computed = results
  {
    "schema_version" => SCHEMA_VERSION,
    "status" => passed?(computed) ? "pass" : "fail",
    "checks" => CHECKS.each_index.map do |i|
      status, message, remediation = computed[i]
      {
        "name" => CHECKS[i].to_s,
        "status" => status.to_s,
        "message" => message,
        "remediation" => remediation
      }
    end
  }
end

#passed?(computed = results) ⇒ Boolean

Returns true when NO check failed — only :pass/:warn are success (mirrors #run's rule; an unexpected status fails).

Parameters:

  • computed (Array<Array>) (defaults to: results)

    result tuples (defaults to a fresh #results run).

Returns:

  • (Boolean)

    true when NO check failed — only :pass/:warn are success (mirrors #run's rule; an unexpected status fails).



114
115
116
# File 'lib/ruact/doctor.rb', line 114

def passed?(computed = results)
  computed.all? { |status, _| SUCCESS_STATUSES.include?(status) }
end

#resultsArray<Array>

Runs every check ONCE and returns the raw result tuples, index-aligned with CHECKS. Each tuple is [status, message] or [status, message, remediation] (the optional 3rd element is a machine-readable fix string, nil when the check has no cleanly separable remediation). Shared by #run (human path) and #as_json (JSON path) so a check like check_vite — which opens a socket — never runs twice. (Story 15.3)

Returns:

  • (Array<Array>)

    one tuple per check, in CHECKS order.



107
108
109
# File 'lib/ruact/doctor.rb', line 107

def results
  CHECKS.map { |check| send(:"check_#{check}") }
end

#runObject



87
88
89
90
91
92
93
94
95
96
97
# File 'lib/ruact/doctor.rb', line 87

def run
  puts "[ruact] Health check"
  computed = results
  computed.each { |status, message| puts format_result(status, message) }
  # A :warn must NOT fail the run (Story 13.1 AC3); only :pass / :warn are
  # success. An unexpected status (rendered `✗`) fails loudly rather than
  # being silently treated as a pass (review finding R1).
  passed = passed?(computed)
  puts "Run rails generate ruact:install to fix configuration issues" unless passed
  passed
end