ruact 0.0.5 → 0.0.6

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +67 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -30,10 +30,12 @@ module Ruact
30
30
  end
31
31
  end
32
32
 
33
- # Minimal test double — `headers` + `format` needed for the methods under test.
34
- # `format` must be a public struct member (not Kernel#format) so that
35
- # verify_partial_doubles can stub it in #default_render tests.
36
- let(:fake_request) { Struct.new(:headers, :format).new({}, nil) }
33
+ # Minimal test double — `headers`, `format`, and `accepts` needed for the
34
+ # methods under test. `format` must be a public struct member (not
35
+ # Kernel#format) so that verify_partial_doubles can stub it. Story 10.0:
36
+ # `#default_render` now keys off `request.accepts` (HTML acceptability), so
37
+ # `accepts` is a settable member too.
38
+ let(:fake_request) { Struct.new(:headers, :format, :accepts).new({}, nil, []) }
37
39
  let(:controller) { test_class.new(fake_request) }
38
40
 
39
41
  describe "#ruact_manifest" do
@@ -71,30 +73,41 @@ module Ruact
71
73
  end
72
74
 
73
75
  describe "#default_render" do
74
- let(:html_format) { Class.new { def html? = true }.new }
75
- let(:json_format) { Class.new { def html? = false }.new }
76
-
76
+ # Story 10.0 the activation predicate keys off HTML *acceptability*
77
+ # (`request.accepts`), not `request.format.html?`. `request.format.html?`
78
+ # is false for a `*/*` wildcard, which 500'd those requests pre-10.0.
77
79
  before do
78
80
  allow(controller).to receive(:ruact_template_exists?).and_return(true)
79
81
  allow(controller).to receive(:ruact_render)
80
82
  end
81
83
 
82
- it "calls ruact_render when format is HTML and template exists (AC#1)" do
83
- allow(fake_request).to receive(:format).and_return(html_format)
84
+ it "calls ruact_render when the client accepts text/html and template exists (AC#1)" do
85
+ fake_request.accepts = [Mime[:html]]
86
+ controller.send(:default_render)
87
+ expect(controller).to have_received(:ruact_render)
88
+ end
89
+
90
+ it "calls ruact_render for a */* wildcard Accept — Mime::ALL (Story 10.0 AC#1)" do
91
+ fake_request.accepts = [Mime::ALL]
92
+ controller.send(:default_render)
93
+ expect(controller).to have_received(:ruact_render)
94
+ end
95
+
96
+ it "calls ruact_render when Accept is blank (defaults to HTML) (Story 10.0 AC#1)" do
97
+ fake_request.accepts = []
84
98
  controller.send(:default_render)
85
99
  expect(controller).to have_received(:ruact_render)
86
100
  end
87
101
 
88
- it "calls ruact_render for RSC requests (text/x-component) even without html? (AC#1)" do
89
- allow(fake_request).to receive(:format).and_return(json_format)
102
+ it "calls ruact_render for RSC requests (text/x-component) even when HTML is unacceptable (AC#1)" do
103
+ fake_request.accepts = [Mime[:json]]
90
104
  fake_request.headers["Ruact-Request"] = "1"
91
105
  controller.send(:default_render)
92
106
  expect(controller).to have_received(:ruact_render)
93
107
  end
94
108
 
95
- it "does NOT call ruact_render when format is not HTML and not RSC (AC#5, AC#6 — FR26)" do
96
- allow(fake_request).to receive(:format).and_return(json_format)
97
- allow(controller).to receive(:ruact_render)
109
+ it "does NOT call ruact_render for a concrete non-HTML Accept that is not RSC (AC#4 — FR26)" do
110
+ fake_request.accepts = [Mime[:json]]
98
111
  begin
99
112
  controller.send(:default_render)
100
113
  rescue StandardError
@@ -105,7 +118,7 @@ module Ruact
105
118
 
106
119
  it "does NOT call ruact_render when no template exists" do
107
120
  allow(controller).to receive(:ruact_template_exists?).and_return(false)
108
- allow(fake_request).to receive(:format).and_return(html_format)
121
+ fake_request.accepts = [Mime[:html]]
109
122
  begin
110
123
  controller.send(:default_render)
111
124
  rescue StandardError
@@ -191,8 +204,11 @@ module Ruact
191
204
  end
192
205
 
193
206
  describe "#ruact_html_shell" do
194
- # vite_tags requires Rails.envstub it so we can test the shell structure.
195
- before { allow(controller).to receive(:vite_tags).and_return("") }
207
+ # Story 14.2the entry `<script>` tags are emitted by the mixed-in
208
+ # ViewHelper#ruact_vite_tags (which needs Rails.env / a live Vite probe).
209
+ # Stub it to "" so we test the shell structure + the __FLIGHT_DATA script
210
+ # (still emitted by ruact_js_assets) in isolation.
211
+ before { allow(controller).to receive(:ruact_vite_tags).and_return("") }
196
212
 
197
213
  let(:payload) { "0:[\"$\",\"div\",null,{}]\n" }
198
214
 
@@ -243,6 +259,12 @@ module Ruact
243
259
  end
244
260
  let(:csrf_controller) { csrf_test_class.new(fake_request) }
245
261
 
262
+ # Story 14.2 — csrf_controller is a separate instance from `controller`,
263
+ # so the outer `before` stub does not reach it. Stub its entry-tag
264
+ # emission too (the real path would hit Rails.root in a non-booted env),
265
+ # keeping these CSRF tests order-independent.
266
+ before { allow(csrf_controller).to receive(:ruact_vite_tags).and_return("") }
267
+
246
268
  it "embeds <meta name=\"csrf-token\" content=\"...\"> when the host exposes form_authenticity_token" do
247
269
  allow(csrf_controller).to receive(:form_authenticity_token).and_return("test-csrf-token-value")
248
270
  html = csrf_controller.send(:ruact_html_shell, payload)
@@ -279,322 +301,37 @@ module Ruact
279
301
  end
280
302
  end
281
303
 
282
- describe "ruact_action DSL macro (Story 8.1)", :story_8_1 do
283
- # The macro is class-level. Each example builds a fresh anonymous
284
- # controller class so registry state is per-example. The registry
285
- # itself is reset at the top of every example via Ruact.action_registry.clear!
286
- # to avoid leakage across spec runs.
287
- before { Ruact.action_registry.clear! }
288
-
289
- it "registers an action in Ruact.action_registry with the correct kind, controller, and block" do
290
- klass = Class.new do
291
- def self.name = "ExampleController"
292
- include Ruact::Controller
293
-
294
- ruact_action(:create_post) { |params| "created #{params[:title]}" }
295
- end
296
-
297
- entry = Ruact.action_registry.entries[:create_post]
298
- expect(entry).not_to be_nil
299
- expect(entry.ruby_symbol).to eq(:create_post)
300
- expect(entry.js_identifier).to eq("createPost")
301
- expect(entry.kind).to eq(:action)
302
- expect(entry.controller).to be(klass)
303
- expect(entry.block).to be_a(Proc)
304
- end
305
-
306
- it "defines the action symbol as a PUBLIC method (Rails action dispatch requires public) " \
307
- "with a thread-local guard that rejects non-endpoint invocations (review-batch 1 2026-05-14)" do
308
- klass = Class.new do
309
- def self.name = "ExampleController"
310
- include Ruact::Controller
311
-
312
- ruact_action(:create_post) { |params| "echo #{params[:title]}" }
313
- end
314
-
315
- instance = klass.allocate
316
- # Public (so ActionController#process can dispatch it through the
317
- # before_action chain when scoped `only: :create_post`).
318
- expect(instance.respond_to?(:create_post)).to be(true)
319
- # Direct call without the thread-local sentinel raises — closes the
320
- # wildcard-route exposure where a host's `get ":controller/:action"`
321
- # could otherwise reach the action via GET.
322
- expect { instance.send(:create_post) }.to raise_error(Ruact::Error, /can only be invoked through POST/)
323
- end
324
-
325
- it "raises ArgumentError when no block is given" do
326
- expect do
327
- Class.new do
328
- def self.name = "BadController"
329
- include Ruact::Controller
330
-
331
- ruact_action(:create_post)
332
- end
333
- end.to raise_error(ArgumentError, /requires a block/)
334
- end
335
-
336
- it "raises Ruact::ConfigurationError with the AC7 prefix on invalid symbol shape" do
337
- expect do
338
- Class.new do
339
- def self.name = "BadController"
340
- include Ruact::Controller
341
-
342
- ruact_action(:CreatePost) { |_p| nil }
343
- end
344
- end.to raise_error(Ruact::ConfigurationError, /invalid server-function symbol :CreatePost in BadController/)
345
- end
346
-
347
- it "raises Ruact::ConfigurationError with the AC7 collision wording on within-registry duplicate js_identifier" do
348
- expect do
349
- Class.new do
350
- def self.name = "CollidingController"
351
- include Ruact::Controller
352
-
353
- ruact_action(:foo_bar) { |_p| nil }
354
- ruact_action(:foo__bar) { |_p| nil }
355
- end
356
- end.to raise_error(Ruact::ConfigurationError, /server-function naming collision.*"fooBar"/m)
357
- end
358
-
359
- it "raises Ruact::ConfigurationError when the symbol maps to a reserved JS word" do
360
- expect do
361
- Class.new do
362
- def self.name = "BadController"
363
- include Ruact::Controller
364
-
365
- ruact_action(:delete) { |_p| nil }
366
- end
367
- end.to raise_error(Ruact::ConfigurationError, /reserved/)
368
- end
369
-
370
- it "raises Ruact::ConfigurationError when the symbol clobbers a framework method " \
371
- "(review-batch 1 2026-05-14)" do
372
- # `:params` is defined on ActionController::Base via Metal — declaring
373
- # `ruact_action :params` would override the request-params accessor.
374
- expect do
375
- Class.new do
376
- def self.name = "BadController"
377
- include Ruact::Controller
378
-
379
- ruact_action(:params) { |_p| nil }
380
- end
381
- end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
382
- end
383
-
384
- it "raises ArgumentError on a zero-arity block (review-batch 1 2026-05-14)" do
385
- expect do
386
- Class.new do
387
- def self.name = "ExampleController"
388
- include Ruact::Controller
389
-
390
- ruact_action(:no_args) { "pong" }
391
- end
392
- end.to raise_error(ArgumentError, /must accept exactly one positional parameter/)
393
- end
394
-
395
- it "rejects a block with required keyword arguments (re-run-4 #4 — " \
396
- "block.parameters guard catches `do |p, required:|`)" do
397
- expect do
398
- Class.new do
399
- def self.name = "ExampleController"
400
- include Ruact::Controller
401
-
402
- ruact_action(:bad_kwargs) { |_params, required:| required }
403
- end
404
- end.to raise_error(ArgumentError, /no required keyword arguments/)
405
- end
406
-
407
- it "accepts optional keyword args alongside the positional (re-run-4 #4 — " \
408
- "`do |params, opt: nil|` is fine)" do
409
- expect do
410
- Class.new do
411
- def self.name = "ExampleController"
412
- include Ruact::Controller
413
-
414
- ruact_action(:opt_kwargs) { |_params, opt: nil| opt }
415
- end
416
- end.not_to raise_error
417
- end
418
-
419
- it "accepts a splat-arity block (do |*args|) since it tolerates one positional arg" do
420
- expect do
421
- Class.new do
422
- def self.name = "ExampleController"
423
- include Ruact::Controller
424
-
425
- ruact_action(:splat_args) { |*args| args.first }
426
- end
427
- end.not_to raise_error
428
- end
429
-
430
- it "rejects a String argument (re-run-2 #4 — String key would 404 on dispatch)" do
431
- expect do
432
- Class.new do
433
- def self.name = "BadController"
434
- include Ruact::Controller
435
-
436
- ruact_action("create_post") { |_p| nil }
437
- end
438
- end.to raise_error(ArgumentError, /requires a Symbol/)
439
- end
440
-
441
- it "rejects clobber of a method already defined on the host class itself " \
442
- "(re-run-2 #3 — guard extended from framework methods to host methods)" do
443
- expect do
444
- Class.new do
445
- def self.name = "BadController"
446
- include Ruact::Controller
447
-
448
- def index; end
449
- ruact_action(:index) { |_p| nil }
450
- end
451
- end.to raise_error(Ruact::ConfigurationError, /would clobber an existing method/)
452
- end
453
-
454
- it "rejects clobber of an INHERITED app helper like :current_user " \
455
- "(re-run-3 #2 — guard extended from own-class to inherited app methods)" do
456
- # Stand-in for `ApplicationController` defining `current_user` and
457
- # subclasses inheriting it; declaring `ruact_action :current_user`
458
- # would silently override the auth helper.
459
- app_controller = Class.new(ActionController::Base) do
460
- def current_user; end
461
- def authenticate_user!; end
462
- end
463
- expect do
464
- Class.new(app_controller) do
465
- def self.name = "BadController"
466
- include Ruact::Controller
467
-
468
- ruact_action(:current_user) { |_p| nil }
469
- end
470
- end.to raise_error(Ruact::ConfigurationError, /would clobber an inherited helper/)
471
- end
472
-
473
- it "rejects clobber of CSRF callback :verify_authenticity_token " \
474
- "(re-run-5 #2 — added to FRAMEWORK_RESERVED_METHODS)" do
475
- expect do
476
- Class.new do
477
- def self.name = "BadController"
478
- include Ruact::Controller
479
-
480
- ruact_action(:verify_authenticity_token) { |_p| nil }
481
- end
482
- end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
483
- end
484
-
485
- it "rejects a block with MULTIPLE required positional parameters " \
486
- "(re-run-5 #3 — `do |a, b|` silently received nil for `b`)" do
487
- expect do
488
- Class.new do
489
- def self.name = "ExampleController"
490
- include Ruact::Controller
491
-
492
- ruact_action(:two_positional) { |_a, _b| nil }
493
- end
494
- end.to raise_error(ArgumentError, /must accept exactly one positional/)
495
- end
496
-
497
- it "rejects clobber of Kernel#send / Kernel#public_send " \
498
- "(re-run-3 #2 — added to FRAMEWORK_RESERVED_METHODS)" do
499
- expect do
500
- Class.new do
501
- def self.name = "BadController"
502
- include Ruact::Controller
503
-
504
- ruact_action(:send) { |_p| nil }
505
- end
506
- end.to raise_error(Ruact::ConfigurationError, /would clobber a framework method/)
507
- end
508
-
509
- it "rejects clobber of an INHERITED ActionController::Base method like :status " \
510
- "(re-run-6 #2 — denylist widened from hardcoded set to all framework methods - Object methods)" do
511
- expect do
512
- Class.new do
513
- def self.name = "BadController"
514
-
515
- include Ruact::Controller
304
+ # Story 14.2 (FR104) the controller's HTML shell delegates its JS asset
305
+ # markup (entry tags + __FLIGHT_DATA) to the single
306
+ # Ruact::ViewHelper#ruact_js_assets implementation. No duplicated tag logic;
307
+ # controller output == a view helper's output (parity guards against drift).
308
+ describe "ruact_js_assets delegation + parity", :story_14_2 do
309
+ let(:payload) { "0:[\"$\",\"div\",null,{}]\n" }
516
310
 
517
- ruact_action(:status) { |_p| nil }
518
- end
519
- end.to raise_error(Ruact::ConfigurationError,
520
- /would clobber an inherited ActionController::Base method/)
311
+ it "delegates the JS block to ruact_js_assets (single implementation)" do
312
+ allow(controller).to receive(:ruact_js_assets).with(payload).and_return("<!--RUACT-JS-->".html_safe)
313
+ html = controller.send(:ruact_html_shell, payload)
314
+ expect(html).to include("<!--RUACT-JS-->")
315
+ expect(controller).to have_received(:ruact_js_assets).with(payload)
521
316
  end
522
317
 
523
- it "rejects a LATER def that overrides a previously-registered " \
524
- "ruact_action method in the same class body (re-run-6 #1 — method_added hook)" do
525
- # The macro defines the action method; a later `def create_post; end`
526
- # would silently shadow it, causing host_class.dispatch to skip the
527
- # sentinel guard and run the user's later body. Detect at class-load
528
- # time via method_added hook so the error is loud at boot.
529
- expect do
530
- Class.new do
531
- def self.name = "BadController"
532
-
533
- include Ruact::Controller
318
+ it "emits markup byte-identical to a standalone view helper (no drift)" do
319
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
320
+ manifest_entry = { "file" => "bootstrap-xyz789.js" }
321
+ allow(controller).to receive(:vite_manifest_entry).and_return(manifest_entry)
534
322
 
535
- ruact_action(:create_post) { |_p| "from macro" }
323
+ view = Object.new
324
+ view.extend(Ruact::ViewHelper)
325
+ allow(view).to receive(:vite_manifest_entry).and_return(manifest_entry)
536
326
 
537
- def create_post
538
- "from later def"
539
- end
540
- end
541
- end.to raise_error(Ruact::ConfigurationError,
542
- /registered by `ruact_action :create_post` and then re-defined/)
327
+ expect(controller.send(:ruact_js_assets, payload)).to eq(view.ruact_js_assets(payload))
543
328
  end
544
329
 
545
- it "preserves a pre-existing `method_added` hook on the host class " \
546
- "(re-run-7 #1 — prepended Module instead of define_method clobber)" do
547
- # Apps and concerns commonly install `method_added` for
548
- # instrumentation, DSL bookkeeping (`attr_*` style helpers), or
549
- # auditing. If the gem replaced the host's `method_added` instead
550
- # of chaining through it, those concerns would stop firing as soon
551
- # as the first `ruact_action` runs.
552
- recorded = []
553
- klass = Class.new do
554
- def self.name = "ExampleController"
555
-
556
- singleton_class.define_method(:method_added) do |meth|
557
- recorded << meth
558
- super(meth)
559
- end
560
-
561
- include Ruact::Controller
562
-
563
- ruact_action(:create_post) { |_p| "from macro" }
564
-
565
- def helper_method
566
- :ok
567
- end
568
- end
569
-
570
- # Both the macro-defined :create_post AND the later `def helper_method`
571
- # should have been observed by the host's `method_added`.
572
- expect(recorded).to include(:create_post, :helper_method)
573
- # The action method is still registered and dispatch-guarded.
574
- expect(klass.allocate.respond_to?(:create_post)).to be(true)
575
- end
576
- end
577
-
578
- describe "ruact_action cross-controller collisions (Story 8.1 — re-run-3)" do
579
- before { Ruact.action_registry.clear! }
580
-
581
- it "raises Ruact::ConfigurationError when the SAME symbol is declared on TWO controllers " \
582
- "(re-run-3 #1 — silent overwrite would route to whichever loaded last)" do
583
- Class.new do
584
- def self.name = "PostsController"
585
- include Ruact::Controller
586
-
587
- ruact_action(:create_post) { |_p| nil }
588
- end
589
-
590
- expect do
591
- Class.new do
592
- def self.name = "AdminPostsController"
593
- include Ruact::Controller
594
-
595
- ruact_action(:create_post) { |_p| nil }
596
- end
597
- end.to raise_error(Ruact::ConfigurationError, /declared in BOTH/)
330
+ it "exposes ruact_js_assets as PRIVATE on the controller (never a routable action)", :aggregate_failures do
331
+ expect(controller.class.public_method_defined?(:ruact_js_assets)).to be false
332
+ expect(controller.class.private_method_defined?(:ruact_js_assets)).to be true
333
+ # __ruact_component__ is likewise demoted so it is not exposed as an action.
334
+ expect(controller.class.public_method_defined?(:__ruact_component__)).to be false
598
335
  end
599
336
  end
600
337
  end
@@ -276,6 +276,207 @@ RSpec.describe Ruact::Doctor do
276
276
  end
277
277
  end
278
278
 
279
+ # --- check_serialize_only (Story 13.1, AC2 + AC4) ---
280
+
281
+ describe "#check_serialize_only", :story_13_1 do
282
+ subject(:doctor) { described_class.new(serialize_only_root: scan_root.to_s) }
283
+
284
+ # Injectable scan root → point the tripwire at a fixture tree.
285
+ let(:scan_root) { Pathname.new(Dir.mktmpdir) }
286
+
287
+ after { FileUtils.rm_rf(scan_root) }
288
+
289
+ def write_source(name, content)
290
+ path = scan_root.join(name)
291
+ FileUtils.mkdir_p(path.dirname)
292
+ File.write(path, content)
293
+ end
294
+
295
+ context "with a clean tree (no inbound deserializer)" do
296
+ before { write_source("clean.rb", "class Foo\n def bar = 42\nend\n") }
297
+
298
+ it "returns :pass silently" do
299
+ status, msg = doctor.send(:check_serialize_only)
300
+ expect(status).to eq(:pass)
301
+ expect(msg).to include("Serialize-only invariant holds")
302
+ end
303
+ end
304
+
305
+ context "with an unguarded inbound Flight deserializer" do
306
+ before do
307
+ write_source("evil.rb", "class FlightDeserializer\n def call(body)\n parse_flight(body)\n end\nend\n")
308
+ end
309
+
310
+ it "returns :fail" do
311
+ status, = doctor.send(:check_serialize_only)
312
+ expect(status).to eq(:fail)
313
+ end
314
+
315
+ it "names the offending file:line and points to the ADR invariant" do
316
+ _, msg = doctor.send(:check_serialize_only)
317
+ expect(msg).to include("evil.rb:1") # first offense = the *Deserializer constant
318
+ expect(msg).to include("CVE-2025-55182")
319
+ expect(msg).to include("server-functions-api.md")
320
+ end
321
+ end
322
+
323
+ context "with a deserializer carrying the allow annotation" do
324
+ before do
325
+ annotation = ["# ruact:allow", "flight", "deserialization"].join("-")
326
+ write_source("guarded.rb", "def parse_flight(body) #{annotation} reviewed legacy bridge\n body\nend\n")
327
+ end
328
+
329
+ it "returns :pass (the escape hatch makes it a guard, not a ban)" do
330
+ status, = doctor.send(:check_serialize_only)
331
+ expect(status).to eq(:pass)
332
+ end
333
+ end
334
+
335
+ context "with a signal that lives in an excluded location" do
336
+ before do
337
+ # generators' client-side templates are out of scope (browser RSC)
338
+ write_source("lib/generators/ruact/install/templates/app.rb", "def parse_flight(b) = b\n")
339
+ end
340
+
341
+ it "returns :pass (templates are excluded from the scan)" do
342
+ status, = doctor.send(:check_serialize_only)
343
+ expect(status).to eq(:pass)
344
+ end
345
+ end
346
+
347
+ context "with a `decode_flight` inbound entry point (review finding R3)" do
348
+ before { write_source("decoder.rb", "def decode_flight(body)\n body\nend\n") }
349
+
350
+ it "returns :fail (decode_flight is an inbound deserialization signal)" do
351
+ status, = doctor.send(:check_serialize_only)
352
+ expect(status).to eq(:fail)
353
+ end
354
+ end
355
+
356
+ context "with a deserializer in a differently-located file ALSO named doctor.rb (review finding R1)" do
357
+ # Only the gem's own lib/ruact/doctor.rb is excluded (by exact path), not
358
+ # every basename `doctor.rb` — a nested namesake must still be scanned.
359
+ before { write_source("ruact/flight/doctor.rb", "class FlightDeserializer; end\n") }
360
+
361
+ it "returns :fail (basename collision is not a free pass)" do
362
+ status, = doctor.send(:check_serialize_only)
363
+ expect(status).to eq(:fail)
364
+ end
365
+ end
366
+ end
367
+
368
+ # --- check_flight_middleware (Story 13.1, AC3 + AC4) ---
369
+
370
+ describe "#check_flight_middleware", :story_13_1 do
371
+ subject(:doctor) { described_class.new }
372
+
373
+ # Plain value objects (not doubles) modelling the iterable middleware stack
374
+ # the check reads — each entry exposes a `.name`, mirroring a real
375
+ # ActionDispatch::MiddlewareStack::Middleware.
376
+ def middleware_entry(name)
377
+ Struct.new(:name).new(name)
378
+ end
379
+
380
+ def stub_app(stack)
381
+ app = Struct.new(:middleware).new(stack)
382
+ allow(Rails).to receive(:application).and_return(app)
383
+ end
384
+
385
+ context "when a response-transforming middleware (Rack::Deflater) is mounted" do
386
+ before { stub_app([middleware_entry("Rack::Deflater")]) }
387
+
388
+ it "returns :warn (never :fail)" do
389
+ status, msg = doctor.send(:check_flight_middleware)
390
+ expect(status).to eq(:warn)
391
+ expect(msg).to include("Rack::Deflater").and include("text/x-component")
392
+ end
393
+ end
394
+
395
+ context "when no response-transforming middleware is mounted" do
396
+ before { stub_app([middleware_entry("Rack::Runtime")]) }
397
+
398
+ it "returns :pass" do
399
+ status, = doctor.send(:check_flight_middleware)
400
+ expect(status).to eq(:pass)
401
+ end
402
+ end
403
+
404
+ context "when no Rails application is present" do
405
+ before { allow(Rails).to receive(:application).and_return(nil) }
406
+
407
+ it "returns :pass (guarded edge context)" do
408
+ status, = doctor.send(:check_flight_middleware)
409
+ expect(status).to eq(:pass)
410
+ end
411
+ end
412
+
413
+ context "when the app is not yet booted (middleware is a non-enumerable proxy)" do
414
+ # Mirrors a pre-`initialize!` Rails::Configuration::MiddlewareStackProxy,
415
+ # which does NOT respond to :each — must be skipped, not crash on filter_map.
416
+ before do
417
+ proxy = Object.new # responds to neither :each nor :filter_map
418
+ app = Struct.new(:middleware).new(proxy)
419
+ allow(Rails).to receive(:application).and_return(app)
420
+ end
421
+
422
+ it "returns :pass without raising" do
423
+ expect { doctor.send(:check_flight_middleware) }.not_to raise_error
424
+ status, = doctor.send(:check_flight_middleware)
425
+ expect(status).to eq(:pass)
426
+ end
427
+ end
428
+ end
429
+
430
+ # --- :warn status semantics (Story 13.1, AC3) ---
431
+
432
+ describe "#format_result with :warn", :story_13_1 do
433
+ subject(:doctor) { described_class.new }
434
+
435
+ it "renders :warn with the ⚠ glyph (not ✗)" do
436
+ expect(doctor.send(:format_result, :warn, "heads up")).to eq("⚠ heads up")
437
+ end
438
+ end
439
+
440
+ describe "#run with an unexpected status (review finding R1)", :story_13_1 do
441
+ before do
442
+ make_manifest
443
+ make_controller(with_include: true)
444
+ make_layout(with_sentinel: true)
445
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
446
+ end
447
+
448
+ it "fails the run when a check returns a status that is neither :pass nor :warn" do
449
+ doctor = described_class.new
450
+ # All other checks pass; a malformed status (rendered ✗) must NOT be
451
+ # silently treated as a pass — only :pass / :warn are success.
452
+ allow(doctor).to receive(:check_streaming).and_return([:error, "broken status"])
453
+ expect(doctor.run).to be false
454
+ end
455
+ end
456
+
457
+ describe ".run with a :warn present (Story 13.1, AC3)", :story_13_1 do
458
+ before do
459
+ make_manifest
460
+ make_controller(with_include: true)
461
+ make_layout(with_sentinel: true)
462
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
463
+ app = Struct.new(:middleware).new([Struct.new(:name).new("Rack::Deflater")])
464
+ allow(Rails).to receive(:application).and_return(app)
465
+ end
466
+
467
+ it "still returns true — a :warn does not fail the run" do
468
+ expect(described_class.run).to be true
469
+ end
470
+
471
+ it "prints the warning glyph" do
472
+ expect { described_class.run }.to output(/⚠.*Rack::Deflater/).to_stdout
473
+ end
474
+
475
+ it "does not print the fix hint" do
476
+ expect { described_class.run }.not_to output(/rails generate/).to_stdout
477
+ end
478
+ end
479
+
279
480
  # --- run / .run ---
280
481
 
281
482
  describe ".run / #run (AC#1, #7)" do
@@ -18,7 +18,10 @@ module Ruact
18
18
  end
19
19
 
20
20
  let(:handler) { handler_class.new }
21
- let(:fake_template) { Object.new }
21
+ # Story 13.5 — the hook now forwards +template.identifier+ into the
22
+ # preprocessor (for contract-violation file:line), so the stand-in template
23
+ # must answer +identifier+.
24
+ let(:fake_template) { Struct.new(:identifier).new("app/views/posts/show.html.erb") }
22
25
 
23
26
  describe "#call" do
24
27
  it "applies ErbPreprocessor.transform to source before calling super" do