react_on_rails 17.0.1 → 17.1.0.rc.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/docs/agent/README.md +26 -0
  4. data/docs/agent/doctor-fix-loop.md +25 -0
  5. data/docs/agent/install-and-upgrade.md +162 -0
  6. data/docs/agent/rsc-adoption.md +31 -0
  7. data/docs/agent/streaming-debug.md +57 -0
  8. data/lib/generators/react_on_rails/base_generator.rb +4 -4
  9. data/lib/generators/react_on_rails/generator_helper.rb +12 -1
  10. data/lib/generators/react_on_rails/install_generator.rb +50 -5
  11. data/lib/generators/react_on_rails/js_dependency_manager.rb +1 -1
  12. data/lib/generators/react_on_rails/pro_generator.rb +5 -3
  13. data/lib/generators/react_on_rails/pro_setup.rb +51 -19
  14. data/lib/generators/react_on_rails/rsc_generator.rb +18 -1
  15. data/lib/generators/react_on_rails/rsc_setup/client_references.rb +63 -25
  16. data/lib/generators/react_on_rails/rsc_setup.rb +141 -39
  17. data/lib/generators/react_on_rails/templates/agent_files/AGENTS.md +31 -3
  18. data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev-static-assets → Procfile.dev-static-assets.tt} +1 -1
  19. data/lib/generators/react_on_rails/templates/base/base/{Procfile.dev → Procfile.dev.tt} +1 -1
  20. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +18 -42
  21. data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_check.rb +215 -0
  22. data/lib/react_on_rails/agent_guardrails/templates/rsc_app_safety_skill.md +71 -0
  23. data/lib/react_on_rails/agent_guardrails.rb +223 -0
  24. data/lib/react_on_rails/dev/service_checker.rb +1 -1
  25. data/lib/react_on_rails/doctor.rb +880 -48
  26. data/lib/react_on_rails/doctor_schema.rb +192 -0
  27. data/lib/react_on_rails/engine.rb +2 -2
  28. data/lib/react_on_rails/helper.rb +17 -10
  29. data/lib/react_on_rails/length_prefixed_parser.rb +22 -14
  30. data/lib/react_on_rails/lenient_json.rb +0 -0
  31. data/lib/react_on_rails/prerender_error.rb +38 -8
  32. data/lib/react_on_rails/react_component/render_options.rb +1 -1
  33. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +168 -15
  34. data/lib/react_on_rails/version.rb +1 -1
  35. data/lib/tasks/agent_guardrails.rake +21 -0
  36. data/rakelib/run_rspec.rake +14 -0
  37. data/sig/react_on_rails/prerender_error.rbs +2 -1
  38. data/skills/doctor-fix-loop/SKILL.md +26 -0
  39. data/skills/install-and-upgrade/SKILL.md +24 -0
  40. data/skills/rsc-adoption/SKILL.md +28 -0
  41. data/skills/streaming-debug/SKILL.md +53 -0
  42. metadata +18 -4
  43. data/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-watch +0 -26
@@ -2,9 +2,11 @@
2
2
 
3
3
  require "json"
4
4
  require "erb"
5
+ require "open3"
5
6
  require "stringio"
6
7
  require "tempfile"
7
8
  require "timeout"
9
+ require "uri"
8
10
  require "yaml"
9
11
  require_relative "utils"
10
12
  require_relative "version"
@@ -15,6 +17,7 @@ require_relative "dev/server_mode"
15
17
  require_relative "version_syntax_converter"
16
18
  require_relative "version_synchronizer"
17
19
  require_relative "system_checker"
20
+ require_relative "doctor_schema"
18
21
  require_relative "pro_migration"
19
22
  require_relative "node_renderer_procfile"
20
23
  require_relative "../generators/react_on_rails/js_dependency_manager"
@@ -116,6 +119,104 @@ module ReactOnRails
116
119
  ].freeze
117
120
  # Per-file safety gate to bound IO during the scan, not a meaningful size limit.
118
121
  RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES = 1_048_576
122
+ NODE_RENDERER_CONFIG_MAX_BYTES = 1_048_576
123
+ NODE_RENDERER_SYNTAX_CHECK_TIMEOUT_SECONDS = 5
124
+ NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS = 0.5
125
+ NODE_RENDERER_ROLLOUT_GENERATIONS = 2
126
+ NODE_RENDERER_CURRENT_GENERATION_MANIFEST_FILENAME = /\Arorp-generation-v1-[0-9a-f]{64}\.json\z/
127
+ NODE_RENDERER_LAUNCHER_PATHS = %w[
128
+ Procfile
129
+ Procfile.dev
130
+ Procfile.dev-static-assets
131
+ Procfile.dev-prod-assets
132
+ Procfile.production
133
+ ].freeze
134
+ NODE_RENDERER_SCRIPT_REFERENCE_PATTERN =
135
+ %r{\bnode\s+(?:\./)?((?:renderer|client)/node-renderer\.js)(?=\s|\z)}
136
+ NODE_RENDERER_DIRECT_LAUNCHER_PATTERN = %r{
137
+ \A[ \t]*[A-Za-z0-9_-]+:[ \t]*
138
+ (?:MAX_VM_POOL_SIZE=(?<assignment>[^\s]+)[ \t]+)?
139
+ node[ \t]+(?:\./)?(?<path>(?:renderer|client)/node-renderer\.js)
140
+ [ \t]*(?:\r?\n)?\z
141
+ }x
142
+ NODE_RENDERER_JS_STRING_PATTERN = /
143
+ "(?:\\.|[^"\\])*" |
144
+ '(?:\\.|[^'\\])*' |
145
+ `(?:\\.|[^`\\])*`
146
+ /mx
147
+ NODE_RENDERER_NESTED_OBJECT_PATTERN = /
148
+ \{
149
+ (?:#{NODE_RENDERER_JS_STRING_PATTERN}|[^{}"'`])*
150
+ \}
151
+ /mx
152
+ NODE_RENDERER_CONFIG_OBJECT_PATTERN = /
153
+ \{
154
+ (?:#{NODE_RENDERER_JS_STRING_PATTERN}|#{NODE_RENDERER_NESTED_OBJECT_PATTERN}|[^{}"'`])*
155
+ \}
156
+ /mx
157
+ NODE_RENDERER_BARE_CALL_PATTERN =
158
+ /(?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\s*\(\s*/
159
+ # A canonical launcher needs one bare renderer call. This generous allowance
160
+ # bounds prefix-based reachability work while tolerating benign decoys.
161
+ MAX_NODE_RENDERER_BARE_CALL_CANDIDATES = 32
162
+ # The canonical export must be a shorthand specifier and the binding must
163
+ # end explicitly. Aliases and trusted-looking expression prefixes are not
164
+ # evidence that the real renderer runs.
165
+ NODE_RENDERER_PACKAGE_BINDING_PATTERN = /
166
+ (?:\A|[;\n])\s*
167
+ (?:
168
+ const\s*\{\s*
169
+ (?:
170
+ [A-Za-z_$][A-Za-z0-9_$]*(?:\s*:\s*[A-Za-z_$][A-Za-z0-9_$]*)?\s*,\s*
171
+ )*
172
+ reactOnRailsProNodeRenderer
173
+ (?:
174
+ \s*,\s*[A-Za-z_$][A-Za-z0-9_$]*(?:\s*:\s*[A-Za-z_$][A-Za-z0-9_$]*)?
175
+ )*
176
+ \s*,?\s*\}\s*=\s*
177
+ require\s*\(\s*["']react-on-rails-pro-node-renderer["']\s*\)
178
+ |
179
+ import\s*\{\s*
180
+ (?:
181
+ [A-Za-z_$][A-Za-z0-9_$]*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*,\s*
182
+ )*
183
+ reactOnRailsProNodeRenderer
184
+ (?:
185
+ \s*,\s*[A-Za-z_$][A-Za-z0-9_$]*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?
186
+ )*
187
+ \s*,?\s*\}\s+from\s+
188
+ ["']react-on-rails-pro-node-renderer["']
189
+ )\s*(?:;|\z)
190
+ /mx
191
+ NODE_RENDERER_REQUIRE_IDENTIFIER_PATTERN = /(?<![.\p{ID_Continue}$#])require\b/
192
+ NODE_RENDERER_REQUIRE_FUNCTION_DECLARATION_PATTERN = /\bfunction\s*\*?\s+require\b/
193
+ NODE_RENDERER_ARGUMENTS_IDENTIFIER_PATTERN = /(?<![.\p{ID_Continue}$#])arguments\b/
194
+ NODE_RENDERER_DIRECT_EVAL_PATTERN = /(?<![.\p{ID_Continue}$#])eval\s*\(/
195
+ NODE_RENDERER_LOCAL_BINDING_PATTERN = %r{
196
+ \b(?:const|let|var)\s+
197
+ (?:reactOnRailsProNodeRenderer\b|\{[^{}]*\breactOnRailsProNodeRenderer\b[^{}]*\})
198
+ |
199
+ \b(?:function|class)\s+reactOnRailsProNodeRenderer\b
200
+ |
201
+ \bimport\s*\{[^{}]*\breactOnRailsProNodeRenderer\b[^{}]*\}\s+from\b
202
+ |
203
+ (?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\b\s*
204
+ (?:\+\+|--|\*\*=|&&=|\|\|=|\?\?=|[+\-*\/%&|^]=|=(?!=|>))
205
+ |
206
+ (?:\+\+|--)\s*(?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\b
207
+ }mx
208
+ NODE_RENDERER_IDENTIFIER_PATTERN =
209
+ /(?<![.\p{ID_Continue}$#])reactOnRailsProNodeRenderer\b/
210
+ NODE_RENDERER_GLOBAL_OBJECT_PATTERN =
211
+ /(?<![.\p{ID_Continue}$#])(?:globalThis|global)\b/
212
+ NODE_RENDERER_UNPROVEN_CALL_CONTROL_PATTERN = /
213
+ (?:&&|\|\||=>|\?) |
214
+ \b(?:if|else|for|while|do|switch|case|catch|finally|function|return|throw)\b
215
+ /x
216
+ NODE_RENDERER_OPENING_DELIMITERS = ["(", "[", "{"].freeze
217
+ NODE_RENDERER_QUOTE_CHARACTERS = ['"', "'"].freeze
218
+ NODE_RENDERER_MATCHING_OPENING_DELIMITERS = { ")" => "(", "]" => "[", "}" => "{" }.freeze
219
+ NODE_RENDERER_UNPROVEN_INITIALIZER_OPENINGS = ["(", "["].freeze
119
220
  # Defense-in-depth cap on how many files a single glob may contribute.
120
221
  # Realistic repos have a handful of workflow / deploy-stage files; far more
121
222
  # than this is a sign of an unexpectedly broad pattern, not legitimate config.
@@ -125,28 +226,11 @@ module ReactOnRails
125
226
  # a machine-readable report (see JSON_SCHEMA_VERSION below).
126
227
  OUTPUT_FORMATS = %i[text json].freeze
127
228
 
128
- # Version of the machine-readable doctor report schema (FORMAT=json).
129
- # Bump ONLY on breaking changes to the shape below. Schema (v1):
130
- #
131
- # {
132
- # "schema_version": 1,
133
- # "ror_version": "<ReactOnRails::VERSION>",
134
- # "status": "pass" | "warn" | "fail", // worst check status
135
- # "checks": [
136
- # {
137
- # "id": "<stable snake_case id from CHECK_SECTIONS>",
138
- # "title": "<human section title>",
139
- # "status": "pass" | "warn" | "fail",
140
- # "message": "<most severe message content, or null>",
141
- # "details": [ { "level": "success|warning|error|info", "content": "..." } ]
142
- # }
143
- # ],
144
- # "summary": { "pass": <count>, "warn": <count>, "fail": <count> }
145
- # }
146
- #
229
+ # DoctorSchema is the canonical machine-readable report contract; bump its
230
+ # version only for breaking changes.
147
231
  # No timestamp is included so output is deterministic for a given app state.
148
232
  # Exit code semantics match text mode: 1 if any check fails, else 0.
149
- JSON_SCHEMA_VERSION = 1
233
+ JSON_SCHEMA_VERSION = DoctorSchema::VERSION
150
234
 
151
235
  # Doctor check sections. The :id values are part of the stable JSON schema
152
236
  # contract (consumed by agents/tooling) — never rename or reuse them; add new
@@ -165,6 +249,8 @@ module ReactOnRails
165
249
  { id: "testing_setup", title: "Testing Setup", method: :check_testing_setup },
166
250
  { id: "development_environment", title: "Development Environment", method: :check_development },
167
251
  { id: "react_on_rails_pro_setup", title: "React on Rails Pro Setup", method: :check_pro_setup },
252
+ { id: "node_renderer_rollout_capacity", title: "Node Renderer Rollout Capacity",
253
+ method: :check_node_renderer_rollout_capacity },
168
254
  { id: "react_server_components", title: "React Server Components", method: :check_rsc_setup }
169
255
  ].freeze
170
256
  CHECK_SECTIONS_BY_ID = CHECK_SECTIONS.to_h { |section| [section[:id], section] }.freeze
@@ -259,7 +345,9 @@ module ReactOnRails
259
345
  stray_output = capture_stdout { results = collect_check_results }
260
346
  $stderr.print(stray_output) unless stray_output.empty?
261
347
 
262
- puts JSON.pretty_generate(build_json_report(results))
348
+ report = build_json_report(results)
349
+ DoctorSchema.validate!(report)
350
+ puts JSON.pretty_generate(report)
263
351
  exit(diagnosis_exit_code)
264
352
  end
265
353
 
@@ -320,29 +408,47 @@ module ReactOnRails
320
408
  {
321
409
  schema_version: JSON_SCHEMA_VERSION,
322
410
  ror_version: ReactOnRails::VERSION,
323
- status: overall_status(statuses),
411
+ status: DoctorSchema.overall_status(statuses),
324
412
  checks:,
325
- summary: {
326
- pass: statuses.count("pass"),
327
- warn: statuses.count("warn"),
328
- fail: statuses.count("fail")
329
- }
413
+ summary: DoctorSchema.summarize_statuses(statuses)
330
414
  }
331
415
  end
332
416
 
333
417
  def build_check_entry(result)
334
418
  messages = result[:messages]
335
419
  status = check_status(messages)
420
+ severity = DoctorSchema::STATUS_SEVERITIES.fetch(status)
421
+ message = primary_message(messages, status)
422
+ metadata = DoctorSchema.metadata(result[:id])
336
423
 
337
424
  {
338
425
  id: result[:id],
339
426
  title: result[:title],
340
427
  status:,
341
- message: primary_message(messages, status),
342
- details: messages.map { |message| { level: message[:type].to_s, content: message[:content] } }
428
+ severity:,
429
+ message:,
430
+ fix_command: nil,
431
+ docs_url: DoctorSchema.docs_url(result[:id]),
432
+ remediation: build_remediation(result[:id], severity, message, metadata, status),
433
+ details: messages.map { |detail| { level: detail[:type].to_s, content: detail[:content] } }
343
434
  }
344
435
  end
345
436
 
437
+ def build_remediation(check_id, severity, message, metadata, status)
438
+ return nil if status == "pass"
439
+
440
+ files = metadata.fetch(:files)
441
+ expected_end_state = metadata.fetch(:expected_end_state)
442
+ prompt_lines = [
443
+ "Fix React on Rails doctor check `#{check_id}` (#{severity}).",
444
+ "Diagnosis: #{message}",
445
+ "Inspect these paths: #{files.join(', ')}.",
446
+ "Expected end state: #{expected_end_state}",
447
+ "Re-run `bin/rails react_on_rails:doctor FORMAT=json` and confirm this check reports `pass`."
448
+ ]
449
+ { prompt: prompt_lines.join("\n"), files:, expected_end_state: }
450
+ end
451
+
346
452
  def check_status(messages)
347
453
  if messages.any? { |message| message[:type] == :error }
348
454
  "fail"
@@ -353,16 +459,6 @@ module ReactOnRails
353
459
  end
354
460
  end
355
461
 
356
- def overall_status(statuses)
357
- if statuses.include?("fail")
358
- "fail"
359
- elsif statuses.include?("warn")
360
- "warn"
361
- else
362
- "pass"
363
- end
364
- end
365
-
366
462
  def primary_message(messages, status)
367
463
  severity = { "fail" => :error, "warn" => :warning }[status]
368
464
  return nil unless severity
@@ -1154,12 +1250,16 @@ module ReactOnRails
1154
1250
 
1155
1251
  layout_name = File.basename(layout_file, ".html.erb")
1156
1252
 
1253
+ # Report detected pack helpers informationally. A JavaScript-only entrypoint
1254
+ # (no emitted CSS), a CSS-only pack, or a hybrid layout that loads CSS via
1255
+ # Propshaft `stylesheet_link_tag` are all valid, so helper asymmetry is not a
1256
+ # warning. See https://github.com/shakacode/react_on_rails/issues/4619.
1157
1257
  if has_stylesheet && has_javascript
1158
1258
  checker.add_info(" ✅ #{layout_name}: has both stylesheet_pack_tag and javascript_pack_tag")
1159
1259
  elsif has_stylesheet
1160
- checker.add_warning(" ⚠️ #{layout_name}: has stylesheet_pack_tag but missing javascript_pack_tag")
1260
+ checker.add_info(" ℹ️ #{layout_name}: has stylesheet_pack_tag")
1161
1261
  elsif has_javascript
1162
- checker.add_warning(" ⚠️ #{layout_name}: has javascript_pack_tag but missing stylesheet_pack_tag")
1262
+ checker.add_info(" ℹ️ #{layout_name}: has javascript_pack_tag")
1163
1263
  else
1164
1264
  checker.add_info(" ℹ️ #{layout_name}: no pack tags found")
1165
1265
  end
@@ -1951,7 +2051,14 @@ module ReactOnRails
1951
2051
  end
1952
2052
 
1953
2053
  def diagnosis_exit_code
1954
- checker.errors? ? 1 : 0
2054
+ status = if checker.errors?
2055
+ :fail
2056
+ elsif checker.warnings?
2057
+ :warn
2058
+ else
2059
+ :pass
2060
+ end
2061
+ DoctorSchema::EXIT_CODES.fetch(status)
1955
2062
  end
1956
2063
 
1957
2064
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -2019,7 +2126,7 @@ module ReactOnRails
2019
2126
  end
2020
2127
 
2021
2128
  def pro_initializer_has_node_renderer?
2022
- config_path = "config/initializers/react_on_rails_pro.rb"
2129
+ config_path = doctor_app_path("config/initializers/react_on_rails_pro.rb")
2023
2130
  return false unless File.exist?(config_path)
2024
2131
 
2025
2132
  File.read(config_path).match?(/server_renderer\s*=\s*["']NodeRenderer["']/)
@@ -2956,10 +3063,10 @@ module ReactOnRails
2956
3063
 
2957
3064
  @rails_environment_attempted = true
2958
3065
 
2959
- env_file = "config/environment.rb"
3066
+ env_file = doctor_app_path("config/environment.rb")
2960
3067
  return false unless File.exist?(env_file)
2961
3068
 
2962
- require File.expand_path(env_file)
3069
+ require env_file
2963
3070
  @rails_environment_loaded = true
2964
3071
  rescue StandardError, LoadError => e
2965
3072
  checker.add_warning(<<~MSG.strip)
@@ -3048,7 +3155,13 @@ module ReactOnRails
3048
3155
  # ── React on Rails Pro Setup ──────────────────────────────────────
3049
3156
 
3050
3157
  def check_pro_setup
3051
- return unless ReactOnRails::Utils.react_on_rails_pro?
3158
+ unless ReactOnRails::Utils.react_on_rails_pro?
3159
+ checker.add_info(<<~MSG.strip)
3160
+ 💎 React on Rails Pro adds the Node Renderer, streaming SSR, and RSC.
3161
+ Upgrade guide: https://reactonrails.com/docs/pro/upgrading-to-pro/
3162
+ MSG
3163
+ return
3164
+ end
3052
3165
 
3053
3166
  check_pro_initializer_existence
3054
3167
  ensure_rails_environment_loaded
@@ -3085,6 +3198,705 @@ module ReactOnRails
3085
3198
  checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}")
3086
3199
  end
3087
3200
 
3201
+ def check_node_renderer_rollout_capacity
3202
+ unless ReactOnRails::Utils.react_on_rails_pro?
3203
+ checker.add_info("ℹ️ NodeRenderer rollout capacity check not applicable — React on Rails Pro is not installed")
3204
+ return
3205
+ end
3206
+
3207
+ rails_environment_loaded = ensure_rails_environment_loaded
3208
+ unless resolved_pro_server_renderer == "NodeRenderer"
3209
+ checker.add_info("ℹ️ NodeRenderer rollout capacity check not applicable — NodeRenderer is not configured")
3210
+ return
3211
+ end
3212
+
3213
+ contexts_per_generation, rsc_evidence = node_renderer_contexts_per_generation(rails_environment_loaded)
3214
+ required_capacity = NODE_RENDERER_ROLLOUT_GENERATIONS * contexts_per_generation
3215
+ topology = node_renderer_endpoint_topology
3216
+ evidence = node_renderer_vm_pool_capacity_evidence(topology)
3217
+
3218
+ checker.add_info(
3219
+ "ℹ️ VM pool formula: generations=#{NODE_RENDERER_ROLLOUT_GENERATIONS} × " \
3220
+ "contexts_per_generation=#{contexts_per_generation} = required_capacity=#{required_capacity} per worker " \
3221
+ "(RSC evidence=#{rsc_evidence})."
3222
+ )
3223
+ if topology == :loopback_endpoint
3224
+ checker.add_info(
3225
+ "ℹ️ topology=loopback_endpoint does not prove whether NodeRenderer shares a container, pod, " \
3226
+ "process environment, or deploy lifecycle with Rails."
3227
+ )
3228
+ end
3229
+ report_node_renderer_capacity_evidence(evidence, required_capacity, topology)
3230
+ rescue StandardError, LoadError
3231
+ checker.add_warning(
3232
+ "⚠️ VM pool rollout capacity is unverified " \
3233
+ "(evidence=unverified, topology=unknown, reason=inspection_error)."
3234
+ )
3235
+ add_node_renderer_capacity_guidance(NODE_RENDERER_ROLLOUT_GENERATIONS * 2)
3236
+ end
3237
+
3238
+ def node_renderer_contexts_per_generation(rails_environment_loaded)
3239
+ if rails_environment_loaded
3240
+ runtime_enabled = node_renderer_runtime_rsc_enabled
3241
+ unless runtime_enabled.nil?
3242
+ enabled = runtime_enabled
3243
+ return [enabled ? 2 : 1, "observed_#{enabled ? 'enabled' : 'disabled'}"]
3244
+ end
3245
+ end
3246
+
3247
+ [2, "unverified_conservative_enabled"]
3248
+ end
3249
+
3250
+ def node_renderer_runtime_rsc_enabled
3251
+ return nil unless defined?(ReactOnRailsPro) && ReactOnRailsPro.respond_to?(:configuration)
3252
+
3253
+ ReactOnRailsPro.configuration.enable_rsc_support
3254
+ end
3255
+
3256
+ def node_renderer_endpoint_topology
3257
+ return :unknown unless defined?(ReactOnRailsPro) && ReactOnRailsPro.respond_to?(:configuration)
3258
+
3259
+ host = URI.parse(ReactOnRailsPro.configuration.renderer_url.to_s).host&.downcase
3260
+ return :unknown unless host
3261
+
3262
+ host = host.delete_prefix("[").delete_suffix("]")
3263
+ return :loopback_endpoint if %w[localhost 127.0.0.1 ::1].include?(host)
3264
+
3265
+ :separate
3266
+ rescue URI::InvalidURIError
3267
+ :unknown
3268
+ end
3269
+
3270
+ def node_renderer_vm_pool_capacity_evidence(topology)
3271
+ return { state: :unverified, reason: "separate_renderer_workload" } if topology == :separate
3272
+ return { state: :unverified, reason: "endpoint_topology_unknown" } unless topology == :loopback_endpoint
3273
+
3274
+ config_path, selection_error = node_renderer_config_path
3275
+ return { state: :unverified, reason: selection_error } unless config_path
3276
+ return { state: :unverified, reason: "canonical_renderer_script_too_large", config_path: } if
3277
+ File.size(doctor_app_path(config_path)) > NODE_RENDERER_CONFIG_MAX_BYTES
3278
+
3279
+ active_content = node_renderer_active_config_content(
3280
+ File.read(doctor_app_path(config_path), NODE_RENDERER_CONFIG_MAX_BYTES).force_encoding(Encoding::UTF_8)
3281
+ )
3282
+ evidence =
3283
+ node_renderer_static_capacity_evidence(active_content) ||
3284
+ node_renderer_env_or_default_capacity_evidence(config_path)
3285
+ evidence = node_renderer_launcher_capacity_with_syntax_evidence(evidence, active_content)
3286
+ evidence.merge(
3287
+ config_path:,
3288
+ current_generation_declaration: node_renderer_current_generation_declaration_evidence(active_content)
3289
+ )
3290
+ end
3291
+
3292
+ def node_renderer_launcher_capacity_with_syntax_evidence(evidence, active_content)
3293
+ return evidence unless evidence[:state] == :observed &&
3294
+ evidence[:source] == "selected_launcher_static_assignment"
3295
+
3296
+ syntax_failure_reason = node_renderer_javascript_syntax_failure_reason(active_content)
3297
+ return evidence unless syntax_failure_reason
3298
+
3299
+ { state: :unverified, reason: syntax_failure_reason }
3300
+ end
3301
+
3302
+ def node_renderer_current_generation_declaration_evidence(active_content)
3303
+ return :unverified if active_content.include?("`")
3304
+
3305
+ config_object = node_renderer_call_config_object(active_content)
3306
+ return :unverified unless config_object
3307
+
3308
+ top_level_content = node_renderer_top_level_config_content(config_object)
3309
+ return :unverified unless top_level_content
3310
+
3311
+ literal_match = top_level_content.match(
3312
+ /
3313
+ (?:\A|,)\s*
3314
+ (?:currentGenerationManifestPath|["']currentGenerationManifestPath["'])\s*:\s*
3315
+ (?<literal>#{NODE_RENDERER_JS_STRING_PATTERN})\s*(?=,|\z)
3316
+ /xo
3317
+ )
3318
+ return :unverified unless literal_match
3319
+
3320
+ literal = literal_match[:literal]
3321
+ return :unverified unless node_renderer_supported_current_generation_declaration_path?(literal)
3322
+
3323
+ :observed
3324
+ end
3325
+
3326
+ def node_renderer_supported_current_generation_declaration_path?(literal)
3327
+ return false unless literal.length > 2
3328
+
3329
+ declaration_path = literal[1...-1]
3330
+ return false if declaration_path.include?("\\")
3331
+
3332
+ path = Pathname.new(declaration_path)
3333
+ path.absolute? &&
3334
+ path.basename.to_s.match?(NODE_RENDERER_CURRENT_GENERATION_MANIFEST_FILENAME) &&
3335
+ path.dirname.basename.to_s == ".current-generations"
3336
+ end
3337
+
3338
+ def node_renderer_config_path
3339
+ existing_paths = [
3340
+ NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH,
3341
+ NodeRendererProcfile::LEGACY_RENDERER_SCRIPT_PATH
3342
+ ].select { |path| File.file?(doctor_app_path(path)) }
3343
+ return [nil, "canonical_renderer_script_missing"] if existing_paths.empty?
3344
+ return [existing_paths.first, nil] if existing_paths.one?
3345
+
3346
+ launched_paths = node_renderer_direct_launcher_script_paths & existing_paths
3347
+ unproven_paths = node_renderer_unproven_launcher_script_paths & existing_paths
3348
+ return [launched_paths.first, nil] if launched_paths.one? && (unproven_paths - launched_paths).empty?
3349
+
3350
+ [nil, "renderer_script_selection_not_proven"]
3351
+ end
3352
+
3353
+ def node_renderer_direct_launcher_script_paths
3354
+ node_renderer_launcher_classifications.filter_map do |classification|
3355
+ classification[:path] if classification[:state] == :proven
3356
+ end.uniq
3357
+ end
3358
+
3359
+ def node_renderer_unproven_launcher_script_paths
3360
+ node_renderer_launcher_classifications.filter_map do |classification|
3361
+ classification[:paths] if classification[:state] == :unproven
3362
+ end.flatten.uniq
3363
+ end
3364
+
3365
+ def node_renderer_launcher_classifications
3366
+ NODE_RENDERER_LAUNCHER_PATHS.flat_map do |path|
3367
+ launcher_path = doctor_app_path(path)
3368
+ next [] unless File.file?(launcher_path)
3369
+ next [] if File.size(launcher_path) > NODE_RENDERER_CONFIG_MAX_BYTES
3370
+
3371
+ File.read(launcher_path, NODE_RENDERER_CONFIG_MAX_BYTES).each_line.filter_map do |line|
3372
+ node_renderer_launcher_line_classification(line)
3373
+ end
3374
+ rescue StandardError
3375
+ []
3376
+ end
3377
+ end
3378
+
3379
+ def node_renderer_launcher_line_classification(line)
3380
+ return if line.match?(/^\s*#/)
3381
+
3382
+ active_line = line.sub(/[ \t]+#.*$/, "")
3383
+ direct_match = active_line.match(NODE_RENDERER_DIRECT_LAUNCHER_PATTERN)
3384
+ if direct_match
3385
+ return {
3386
+ state: :proven,
3387
+ path: direct_match[:path],
3388
+ assignment: direct_match[:assignment]
3389
+ }
3390
+ end
3391
+
3392
+ referenced_paths = active_line.scan(NODE_RENDERER_SCRIPT_REFERENCE_PATTERN).flatten.uniq
3393
+ return if referenced_paths.empty?
3394
+
3395
+ { state: :unproven, paths: referenced_paths }
3396
+ end
3397
+
3398
+ def node_renderer_static_capacity_evidence(active_content)
3399
+ return { state: :unverified, reason: "ambiguous_javascript_configuration" } if active_content.include?("`")
3400
+ if node_renderer_regexp_call_lookalike?(active_content)
3401
+ return { state: :unverified, reason: "ambiguous_javascript_configuration" }
3402
+ end
3403
+
3404
+ mentions = active_content.scan(/\bmaxVMPoolSize\b/)
3405
+ return nil if mentions.empty?
3406
+
3407
+ config_object = node_renderer_call_config_object(active_content)
3408
+ return { state: :unverified, reason: "renderer_configuration_binding_not_proven" } unless config_object
3409
+
3410
+ syntax_failure_reason = node_renderer_javascript_syntax_failure_reason(active_content)
3411
+ return { state: :unverified, reason: syntax_failure_reason } if syntax_failure_reason
3412
+
3413
+ node_renderer_config_object_capacity_evidence(config_object, mentions.length)
3414
+ end
3415
+
3416
+ def node_renderer_javascript_syntax_failure_reason(content)
3417
+ package_binding = node_renderer_canonical_package_binding(content)
3418
+ return "renderer_configuration_binding_not_proven" unless package_binding
3419
+
3420
+ input_type = package_binding[0].match?(/\bimport\s*\{/) ? "module" : "commonjs"
3421
+ syntax_status = node_renderer_javascript_syntax_status(content, input_type)
3422
+ return "renderer_javascript_syntax_invalid" if syntax_status == :invalid
3423
+ return "renderer_javascript_syntax_not_proven" unless syntax_status == :valid
3424
+
3425
+ nil
3426
+ rescue StandardError
3427
+ # Static capacity is positive evidence. If Node is absent, too old for
3428
+ # this check, or unexpectedly fails, keep the diagnosis conservative.
3429
+ "renderer_javascript_syntax_not_proven"
3430
+ end
3431
+
3432
+ def node_renderer_javascript_syntax_status(content, input_type)
3433
+ suffix = input_type == "module" ? ".mjs" : ".cjs"
3434
+ Tempfile.create(["react-on-rails-doctor-renderer", suffix]) do |file|
3435
+ file.binmode
3436
+ file.write(content)
3437
+ file.flush
3438
+ node_renderer_syntax_check_status(file.path)
3439
+ end
3440
+ end
3441
+
3442
+ def node_renderer_syntax_check_status(path)
3443
+ pid = nil
3444
+ process_reaped = false
3445
+ pid = Process.spawn(
3446
+ { "NODE_OPTIONS" => nil },
3447
+ *node_renderer_syntax_check_command(path),
3448
+ out: File::NULL,
3449
+ err: File::NULL,
3450
+ pgroup: true
3451
+ )
3452
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + NODE_RENDERER_SYNTAX_CHECK_TIMEOUT_SECONDS
3453
+
3454
+ loop do
3455
+ _waited_pid, status = Process.wait2(pid, Process::WNOHANG)
3456
+ if status
3457
+ process_reaped = true
3458
+ return status.success? ? :valid : :invalid
3459
+ end
3460
+
3461
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
3462
+ terminate_node_renderer_syntax_check(pid)
3463
+ process_reaped = true
3464
+ return :unavailable
3465
+ end
3466
+
3467
+ sleep 0.05
3468
+ end
3469
+ ensure
3470
+ terminate_node_renderer_syntax_check(pid) if pid && !process_reaped
3471
+ end
3472
+
3473
+ def node_renderer_syntax_check_command(path)
3474
+ ["node", "--check", path]
3475
+ end
3476
+
3477
+ def terminate_node_renderer_syntax_check(pid)
3478
+ signal_node_renderer_syntax_check("TERM", pid)
3479
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
3480
+ NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS
3481
+ while node_renderer_syntax_check_process_group_alive?(pid) &&
3482
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
3483
+ sleep 0.05
3484
+ end
3485
+
3486
+ if node_renderer_syntax_check_process_group_alive?(pid)
3487
+ signal_node_renderer_syntax_check("KILL", pid)
3488
+ wait_for_node_renderer_syntax_check_process_group_exit(pid)
3489
+ end
3490
+ reap_node_renderer_syntax_check(pid)
3491
+ end
3492
+
3493
+ def reap_node_renderer_syntax_check(pid)
3494
+ Process.detach(pid).join(NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS)
3495
+ rescue Errno::ECHILD, Errno::ESRCH
3496
+ nil
3497
+ end
3498
+
3499
+ def node_renderer_syntax_check_process_group_alive?(pid)
3500
+ Process.kill(0, -pid)
3501
+ true
3502
+ rescue Errno::ESRCH
3503
+ false
3504
+ rescue Errno::EPERM
3505
+ node_renderer_syntax_check_group_alive_after_eperm?(pid)
3506
+ end
3507
+
3508
+ def node_renderer_syntax_check_group_alive_after_eperm?(pid)
3509
+ Process.getpgid(pid)
3510
+ false
3511
+ rescue Errno::ESRCH
3512
+ # A competing waiter reaped the leader while a descendant can still own
3513
+ # the process group. Preserve cleanup for that group-only state.
3514
+ true
3515
+ rescue Errno::EPERM
3516
+ false
3517
+ end
3518
+
3519
+ def wait_for_node_renderer_syntax_check_process_group_exit(pid)
3520
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
3521
+ NODE_RENDERER_SYNTAX_CHECK_TERMINATION_GRACE_SECONDS
3522
+ while node_renderer_syntax_check_process_group_alive?(pid) &&
3523
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
3524
+ sleep 0.01
3525
+ end
3526
+ end
3527
+
3528
+ def signal_node_renderer_syntax_check(signal, pid)
3529
+ Process.kill(signal, -pid)
3530
+ rescue Errno::ESRCH, Errno::EPERM
3531
+ # The syntax checker was spawned as its own process-group leader. Once
3532
+ # that group is gone or unsignalable, never fall back to the positive
3533
+ # PID: it may be reused by an unrelated process.
3534
+ nil
3535
+ end
3536
+
3537
+ def node_renderer_config_object_capacity_evidence(config_object, total_mentions)
3538
+ top_level_content = node_renderer_top_level_config_content(config_object)
3539
+ return { state: :unverified, reason: "renderer_configuration_binding_not_proven" } unless top_level_content
3540
+
3541
+ top_level_syntax = top_level_content.gsub(NODE_RENDERER_JS_STRING_PATTERN, " string ")
3542
+ top_level_mentions = top_level_content.scan(/\bmaxVMPoolSize\b/)
3543
+ return { state: :unverified, reason: "renderer_configuration_binding_not_proven" } if top_level_mentions.empty?
3544
+ return { state: :unverified, reason: "ambiguous_javascript_configuration" } unless
3545
+ top_level_mentions.one? && top_level_mentions.length == total_mentions
3546
+ return { state: :unverified, reason: "ambiguous_javascript_configuration" } if
3547
+ node_renderer_config_object_ambiguous?(config_object, top_level_syntax)
3548
+ return { state: :unverified, reason: "dynamic_javascript_configuration" } if top_level_syntax.include?("...")
3549
+
3550
+ literal_match = top_level_content.match(
3551
+ /(?:\A|,)\s*(?:maxVMPoolSize|["']maxVMPoolSize["'])\s*:\s*(0|[1-9]\d*)\s*(?=,|\z)/
3552
+ )
3553
+ return { state: :observed, value: literal_match[1].to_i, source: "canonical_renderer_script" } if literal_match
3554
+
3555
+ { state: :unverified, reason: "dynamic_javascript_configuration" }
3556
+ end
3557
+
3558
+ def node_renderer_config_object_ambiguous?(config_object, top_level_syntax)
3559
+ accessor_syntax = top_level_syntax.gsub(%r{/\*.*?\*/|//[^\n\r\u2028\u2029]*}m, " ")
3560
+
3561
+ top_level_syntax.include?("[") ||
3562
+ config_object.include?("\\") ||
3563
+ accessor_syntax.match?(/(?:\A|,)\s*(?:get|set)\s+[^\s,():]+\s*\(/)
3564
+ end
3565
+
3566
+ def node_renderer_call_config_object(active_content)
3567
+ package_binding = node_renderer_canonical_package_binding(active_content)
3568
+ return nil unless package_binding
3569
+ return nil if node_renderer_local_renderer_binding?(active_content)
3570
+
3571
+ call = node_renderer_single_reachable_call(active_content)
3572
+ return nil unless call && package_binding.end(0) <= call.begin(0)
3573
+
3574
+ argument = active_content[call.end(0)..]
3575
+
3576
+ argument.match(/\A(#{NODE_RENDERER_CONFIG_OBJECT_PATTERN})\s*\)/o)&.[](1)
3577
+ end
3578
+
3579
+ def node_renderer_single_reachable_call(content)
3580
+ masked_content = node_renderer_mask_quoted_string_contents(content)
3581
+ return unless masked_content
3582
+
3583
+ delimiters = node_renderer_delimiter_stack(masked_content)
3584
+ return unless delimiters&.empty?
3585
+
3586
+ candidate_calls = node_renderer_bare_call_candidates(masked_content)
3587
+ calls = candidate_calls.filter_map do |call|
3588
+ next if node_renderer_constructor_call?(masked_content, call)
3589
+
3590
+ call if node_renderer_call_reachability_proven?(masked_content, call)
3591
+ end
3592
+
3593
+ calls.first if calls.one?
3594
+ end
3595
+
3596
+ def node_renderer_bare_call_candidates(content)
3597
+ candidates = []
3598
+ content.to_enum(:scan, NODE_RENDERER_BARE_CALL_PATTERN).each do
3599
+ return [] if candidates.length >= MAX_NODE_RENDERER_BARE_CALL_CANDIDATES
3600
+
3601
+ candidates << Regexp.last_match
3602
+ end
3603
+ candidates
3604
+ end
3605
+
3606
+ def node_renderer_canonical_package_binding(content)
3607
+ masked_content = node_renderer_mask_quoted_string_contents(content)
3608
+ return unless masked_content
3609
+
3610
+ bindings = content.to_enum(:scan, NODE_RENDERER_PACKAGE_BINDING_PATTERN).filter_map do
3611
+ binding = Regexp.last_match
3612
+ binding if node_renderer_proven_package_binding_match?(binding, masked_content)
3613
+ end
3614
+ return unless bindings.one?
3615
+
3616
+ binding = bindings.first
3617
+ return if node_renderer_commonjs_require_rebound?(binding, masked_content)
3618
+
3619
+ prefix = masked_content[...binding.begin(0)]
3620
+ delimiters = node_renderer_delimiter_stack(prefix)
3621
+ binding if delimiters&.empty?
3622
+ end
3623
+
3624
+ def node_renderer_commonjs_require_rebound?(binding, masked_content)
3625
+ return false unless binding[0].match?(/\brequire\s*\(/)
3626
+
3627
+ return true if masked_content.match?(NODE_RENDERER_DIRECT_EVAL_PATTERN)
3628
+ return true if masked_content.match?(NODE_RENDERER_REQUIRE_FUNCTION_DECLARATION_PATTERN)
3629
+ return true if masked_content.match?(NODE_RENDERER_ARGUMENTS_IDENTIFIER_PATTERN)
3630
+
3631
+ masked_content.to_enum(:scan, NODE_RENDERER_REQUIRE_IDENTIFIER_PATTERN).any? do
3632
+ require_identifier = Regexp.last_match
3633
+ !masked_content[require_identifier.end(0)..].match?(/\A\s*\(/)
3634
+ end
3635
+ end
3636
+
3637
+ def node_renderer_proven_package_binding_match?(binding, masked_content)
3638
+ return false if !binding.begin(0).zero? && masked_content[binding.begin(0)] == " "
3639
+
3640
+ binding[0].scan(NODE_RENDERER_IDENTIFIER_PATTERN).one?
3641
+ end
3642
+
3643
+ def node_renderer_local_renderer_binding?(content)
3644
+ without_package_binding = content.gsub(NODE_RENDERER_PACKAGE_BINDING_PATTERN, " ")
3645
+ masked_content = node_renderer_mask_quoted_string_contents(without_package_binding)
3646
+ return true unless masked_content
3647
+
3648
+ node_renderer_global_object_reference?(without_package_binding) ||
3649
+ masked_content.match?(NODE_RENDERER_LOCAL_BINDING_PATTERN) ||
3650
+ node_renderer_non_call_identifier_reference?(masked_content)
3651
+ end
3652
+
3653
+ def node_renderer_global_object_reference?(content)
3654
+ masked_content = node_renderer_mask_quoted_string_contents(content)
3655
+ return true unless masked_content
3656
+
3657
+ masked_content.include?("\\u") || masked_content.match?(NODE_RENDERER_GLOBAL_OBJECT_PATTERN)
3658
+ end
3659
+
3660
+ def node_renderer_non_call_identifier_reference?(content)
3661
+ identifier_positions = content.to_enum(:scan, NODE_RENDERER_IDENTIFIER_PATTERN).map do
3662
+ Regexp.last_match.begin(0)
3663
+ end
3664
+ call_positions = content.to_enum(:scan, NODE_RENDERER_BARE_CALL_PATTERN).map do
3665
+ Regexp.last_match.begin(0)
3666
+ end
3667
+
3668
+ (identifier_positions - call_positions).any?
3669
+ end
3670
+
3671
+ def node_renderer_call_reachability_proven?(content, call)
3672
+ prefix = content[...call.begin(0)]
3673
+ return false unless node_renderer_call_at_top_level?(prefix)
3674
+ return false if node_renderer_call_in_unproven_initializer?(prefix)
3675
+
3676
+ !prefix.match?(NODE_RENDERER_UNPROVEN_CALL_CONTROL_PATTERN)
3677
+ end
3678
+
3679
+ def node_renderer_call_at_top_level?(prefix)
3680
+ brace_depth = 0
3681
+ prefix.each_char do |character|
3682
+ brace_depth += 1 if character == "{"
3683
+ brace_depth -= 1 if character == "}"
3684
+ return false if brace_depth.negative?
3685
+ end
3686
+
3687
+ brace_depth.zero?
3688
+ end
3689
+
3690
+ def node_renderer_delimiter_stack(content)
3691
+ delimiters = []
3692
+
3693
+ content.each_char.with_index do |character, index|
3694
+ delimiters << [character, index] if NODE_RENDERER_OPENING_DELIMITERS.include?(character)
3695
+ next unless NODE_RENDERER_MATCHING_OPENING_DELIMITERS.key?(character)
3696
+
3697
+ opening = delimiters.pop
3698
+ return nil unless opening&.first == NODE_RENDERER_MATCHING_OPENING_DELIMITERS.fetch(character)
3699
+ end
3700
+
3701
+ delimiters
3702
+ end
3703
+
3704
+ def node_renderer_call_in_unproven_initializer?(prefix)
3705
+ delimiters = node_renderer_delimiter_stack(prefix)
3706
+ return true unless delimiters
3707
+
3708
+ delimiters.any? do |opening, index|
3709
+ next false unless NODE_RENDERER_UNPROVEN_INITIALIZER_OPENINGS.include?(opening)
3710
+
3711
+ prefix[(index + 1)..].match?(/(?<![=!<>])=(?!=|>)/)
3712
+ end
3713
+ end
3714
+
3715
+ def node_renderer_constructor_call?(content, call)
3716
+ return false if call.begin(0).zero?
3717
+
3718
+ operator_end = node_renderer_constructor_operator_end(content, call.begin(0))
3719
+ return false unless operator_end && operator_end >= 2
3720
+
3721
+ operator_start = operator_end - 2
3722
+ return false unless content[operator_start, 3] == "new"
3723
+
3724
+ preceding_character = content[operator_start - 1] if operator_start.positive?
3725
+ !preceding_character&.match?(/[.\p{ID_Continue}$#]/)
3726
+ end
3727
+
3728
+ def node_renderer_constructor_operator_end(content, call_start)
3729
+ operator_end = content.rindex(/\S/, call_start - 1)
3730
+ while operator_end&.positive? && content[operator_end] == "("
3731
+ operator_end = content.rindex(/\S/, operator_end - 1)
3732
+ end
3733
+ return nil if operator_end&.zero? && content[operator_end] == "("
3734
+
3735
+ operator_end
3736
+ end
3737
+
3738
+ def node_renderer_mask_quoted_string_contents(content)
3739
+ masked_content = content.dup
3740
+ quote = nil
3741
+ escaped = false
3742
+
3743
+ content.each_char.with_index do |character, index|
3744
+ quote, escaped = node_renderer_mask_quoted_character(masked_content, character, index, quote, escaped)
3745
+ end
3746
+
3747
+ masked_content unless quote
3748
+ end
3749
+
3750
+ def node_renderer_mask_quoted_character(masked_content, character, index, quote, escaped)
3751
+ return [character, false] if quote.nil? && NODE_RENDERER_QUOTE_CHARACTERS.include?(character)
3752
+ return [nil, false] unless quote
3753
+ return [nil, false] if character == quote && !escaped
3754
+
3755
+ masked_content[index] = " "
3756
+ return [quote, false] if escaped
3757
+
3758
+ [quote, character == "\\"]
3759
+ end
3760
+
3761
+ def node_renderer_regexp_call_lookalike?(content)
3762
+ masked_content = node_renderer_mask_quoted_string_contents(content)
3763
+ return true unless masked_content
3764
+
3765
+ masked_content.each_line.any? do |line|
3766
+ first_call = line.index("reactOnRailsProNodeRenderer")
3767
+ next false unless first_call
3768
+
3769
+ first_slash = line.index("/")
3770
+ last_slash = line.rindex("/")
3771
+ last_call = line.rindex("reactOnRailsProNodeRenderer")
3772
+
3773
+ first_slash && last_slash && first_slash < last_call && last_slash > first_call
3774
+ end
3775
+ end
3776
+
3777
+ def node_renderer_top_level_config_content(config_object)
3778
+ content = config_object[1...-1].gsub(NODE_RENDERER_NESTED_OBJECT_PATTERN, "")
3779
+ return nil if content.match?(/[{}]/)
3780
+
3781
+ content
3782
+ end
3783
+
3784
+ def node_renderer_env_or_default_capacity_evidence(config_path)
3785
+ launcher_evidence = node_renderer_launcher_vm_pool_assignment_evidence(config_path)
3786
+ return launcher_evidence if launcher_evidence
3787
+
3788
+ env_value = ENV.fetch("MAX_VM_POOL_SIZE", nil)
3789
+ if env_value
3790
+ unless env_value.match?(/\A[1-9]\d*\z/)
3791
+ return { state: :unverified, reason: "invalid_doctor_process_environment_value" }
3792
+ end
3793
+
3794
+ return { state: :unverified, reason: "renderer_process_environment_not_proven" }
3795
+ end
3796
+
3797
+ { state: :unverified, reason: "package_default_not_proven" }
3798
+ end
3799
+
3800
+ def node_renderer_launcher_vm_pool_assignment_evidence(config_path)
3801
+ classifications = node_renderer_launcher_classifications
3802
+ if classifications.any? do |classification|
3803
+ classification[:state] == :unproven && classification[:paths].include?(config_path)
3804
+ end
3805
+ return { state: :unverified, reason: "dynamic_or_ambiguous_launcher_vm_pool_assignment" }
3806
+ end
3807
+
3808
+ selected_launchers = classifications.select do |classification|
3809
+ classification[:state] == :proven && classification[:path] == config_path
3810
+ end
3811
+ assignment_matches = selected_launchers.map { |classification| classification[:assignment] }
3812
+ node_renderer_launcher_assignment_values_evidence(assignment_matches)
3813
+ end
3814
+
3815
+ def node_renderer_launcher_assignment_values_evidence(assignment_matches)
3816
+ assignments = assignment_matches.compact
3817
+ return nil if assignments.empty?
3818
+ return { state: :unverified, reason: "dynamic_or_ambiguous_launcher_vm_pool_assignment" } unless
3819
+ assignments.length == assignment_matches.length &&
3820
+ assignments.all? { |assignment| assignment.match?(/\A[1-9]\d*\z/) } &&
3821
+ assignments.uniq.one?
3822
+
3823
+ {
3824
+ state: :observed,
3825
+ value: assignments.first.to_i,
3826
+ source: "selected_launcher_static_assignment"
3827
+ }
3828
+ end
3829
+
3830
+ def node_renderer_active_config_content(content)
3831
+ return content if content.include?("`")
3832
+
3833
+ content.gsub(
3834
+ %r{(#{NODE_RENDERER_JS_STRING_PATTERN})|/\*.*?\*/|//[^\n\r\u2028\u2029]*}mxo
3835
+ ) { Regexp.last_match(1) || " " }
3836
+ end
3837
+
3838
+ def report_node_renderer_capacity_evidence(evidence, required_capacity, topology)
3839
+ evidence_state = evidence.fetch(:state)
3840
+ topology_label = topology.to_s
3841
+
3842
+ if evidence_state == :unverified
3843
+ checker.add_warning(
3844
+ "⚠️ VM pool rollout capacity is unverified " \
3845
+ "(evidence=unverified, topology=#{topology_label}, reason=#{evidence.fetch(:reason)}). " \
3846
+ "Doctor does not query the live renderer process."
3847
+ )
3848
+ add_node_renderer_unverified_capacity_guidance(evidence, required_capacity)
3849
+ return
3850
+ end
3851
+
3852
+ configured_capacity = evidence.fetch(:value)
3853
+ evidence_summary =
3854
+ "evidence=#{evidence_state}, configured=#{configured_capacity}, required=#{required_capacity}, " \
3855
+ "topology=#{topology_label}, source=#{evidence.fetch(:source)}, " \
3856
+ "current_generation_declaration=#{evidence.fetch(:current_generation_declaration, :unverified)}, " \
3857
+ "scope=configuration_not_live_process"
3858
+ if configured_capacity >= required_capacity
3859
+ if evidence[:current_generation_declaration] == :observed
3860
+ checker.add_success("✅ Declared-current VM pool rollout capacity is sufficient (#{evidence_summary}).")
3861
+ else
3862
+ checker.add_success("✅ VM pool rollout capacity is sufficient (#{evidence_summary}).")
3863
+ checker.add_warning(
3864
+ "⚠️ VM pool capacity is sufficient but declared-current prewarm is unverified (#{evidence_summary})."
3865
+ )
3866
+ add_node_renderer_current_generation_guidance(evidence[:config_path])
3867
+ end
3868
+ else
3869
+ checker.add_warning("⚠️ VM pool rollout capacity is insufficient (#{evidence_summary}).")
3870
+ add_node_renderer_capacity_guidance(required_capacity, evidence[:config_path])
3871
+ end
3872
+ end
3873
+
3874
+ def add_node_renderer_unverified_capacity_guidance(evidence, required_capacity)
3875
+ unless evidence.fetch(:reason).start_with?("renderer_javascript_syntax_")
3876
+ add_node_renderer_capacity_guidance(required_capacity, evidence[:config_path])
3877
+ return
3878
+ end
3879
+
3880
+ config_path = evidence[:config_path] || NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH
3881
+ checker.add_info("💡 Fix JavaScript syntax in #{config_path}, then rerun Doctor to prove capacity.")
3882
+ end
3883
+
3884
+ def add_node_renderer_current_generation_guidance(config_path = nil)
3885
+ config_path ||= NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH
3886
+ checker.add_info(
3887
+ "💡 Configure RENDERER_CURRENT_GENERATION_MANIFEST in each NodeRenderer revision, or set " \
3888
+ "currentGenerationManifestPath in #{config_path}, to the immutable declaration emitted by pre-seeding."
3889
+ )
3890
+ end
3891
+
3892
+ def add_node_renderer_capacity_guidance(required_capacity, config_path = nil)
3893
+ config_path ||= NodeRendererProcfile::NEW_RENDERER_SCRIPT_PATH
3894
+ checker.add_info(
3895
+ "💡 Set MAX_VM_POOL_SIZE=#{required_capacity} in the NodeRenderer workload, or set " \
3896
+ "maxVMPoolSize: #{required_capacity} in #{config_path}. The hard cap applies per worker."
3897
+ )
3898
+ end
3899
+
3088
3900
  def check_deprecated_renderer_cache_task
3089
3901
  # Resolve against Rails.root (not Dir.pwd) so the scan still fires when
3090
3902
  # doctor is invoked from a subdirectory — otherwise the checks silently
@@ -4858,9 +5670,29 @@ module ReactOnRails
4858
5670
  def doctor_app_root
4859
5671
  rails_root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
4860
5672
  rails_root = rails_root.to_s
4861
- rails_root.empty? ? Dir.pwd : rails_root
5673
+ return rails_root unless rails_root.empty?
5674
+
5675
+ @doctor_app_root ||= doctor_app_root_from_cwd
4862
5676
  rescue StandardError
4863
- Dir.pwd
5677
+ @doctor_app_root ||= doctor_app_root_from_cwd
5678
+ end
5679
+
5680
+ def doctor_app_root_from_cwd
5681
+ start_path = File.expand_path(Dir.pwd)
5682
+ candidate = start_path
5683
+ loop do
5684
+ return candidate if File.file?(File.join(candidate, "config/environment.rb"))
5685
+
5686
+ parent = File.dirname(candidate)
5687
+ break if parent == candidate
5688
+
5689
+ candidate = parent
5690
+ end
5691
+ start_path
5692
+ end
5693
+
5694
+ def doctor_app_path(relative_path)
5695
+ File.expand_path(relative_path, doctor_app_root)
4864
5696
  end
4865
5697
 
4866
5698
  def report_missing_rsc_artifact(label, artifact_path)