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
data/lib/ruact/doctor.rb CHANGED
@@ -7,7 +7,7 @@ module Ruact
7
7
  # Runs a suite of installation health checks and prints ✓/✗ per check.
8
8
  # Extracted from the ruact:doctor Rake task for direct testability (FR27).
9
9
  class Doctor
10
- CHECKS = %i[manifest vite controller layout streaming legacy_constant].freeze
10
+ CHECKS = %i[manifest vite controller layout streaming legacy_constant serialize_only flight_middleware].freeze
11
11
  # Built via Array#join so the gem-CI `name-propagation` guard does not
12
12
  # match these literals against itself (Story 5.1 review F4 — the doctor
13
13
  # file participates in the guard with no exclusion).
@@ -17,7 +17,61 @@ module Ruact
17
17
  LEGACY_SCAN_GLOBS = ["config/initializers/**/*.rb", "app/**/*.rb"].freeze
18
18
  RENAME_DOC_URL = "https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed"
19
19
 
20
- # Runs all checks, prints results, returns true if all pass.
20
+ # --- Story 13.1: serialize-only invariant tripwire (FR97) ---------------
21
+ #
22
+ # ruact EMITS React Flight (`text/x-component`) but must never DESERIALIZE
23
+ # externally-supplied Flight into live Ruby objects — that keeps it outside
24
+ # the React2Shell / CVE-2025-55182 class (a Flight-deserialization RCE). See
25
+ # the ADR addendum in docs/internal/decisions/server-functions-api.md.
26
+ #
27
+ # The signal literals are assembled from fragments via Array#join so this
28
+ # file does NOT itself contain the matched strings (mirrors the LEGACY_CONST
29
+ # F4 lesson). `doctor.rb` is also excluded from the scan as defense in depth.
30
+ # Only structural inbound-deserialization signals are used; the raw
31
+ # `text/x-component` token is deliberately NOT a signal because the gem
32
+ # legitimately EMITS that media type (controller.rb / server.rb) — matching
33
+ # it would false-fail the current, invariant-holding tree.
34
+ DESERIALIZE_SIGNALS = [
35
+ # a `Deserializer` constant reference (FlightDeserializer, Flight::Deserializer, …);
36
+ # matched as a substring so both the joined and namespaced forms trip it
37
+ /#{%w[Deser ializer].join}/,
38
+ # methods that turn inbound Flight into Ruby objects
39
+ /\b#{%w[deserialize flight].join('_')}\b/,
40
+ /\b#{%w[from flight].join('_')}\b/,
41
+ /\b#{%w[parse flight].join('_')}\b/,
42
+ /\b#{%w[decode flight].join('_')}\b/,
43
+ # React Flight reader entry points invoked from Ruby (NOT createFromFlightPayload,
44
+ # which is the client/browser deserializing the server's own trusted payload)
45
+ /\b#{%w[create From].join}(?:NodeStream|ReadableStream|Fetch)\b/
46
+ ].freeze
47
+ DESERIALIZE_SIGNAL_RE = Regexp.union(DESERIALIZE_SIGNALS)
48
+ # A line carrying this annotation is a deliberate, reviewed deserializer and
49
+ # is treated as guarded (the check is a guard, not a blanket ban).
50
+ ALLOW_FLIGHT_DESERIALIZATION = ["# ruact:allow", "flight", "deserialization"].join("-")
51
+ # Exclude THIS file by its exact path (not basename) — its comments contain
52
+ # the literal `Deserializer` example, so it must not match its own scan; but
53
+ # a differently-located future file also named `doctor.rb` must still be
54
+ # scanned (review finding R1 — basename exclusion was too broad).
55
+ DOCTOR_FILE = File.expand_path(__FILE__)
56
+ SERIALIZE_ONLY_DOC = "docs/internal/decisions/server-functions-api.md (serialize-only invariant, FR97)"
57
+
58
+ # Response-transforming middleware that can mutate/recompress a streamed
59
+ # `text/x-component` Flight body and break the wire contract (React-on-Rails
60
+ # ops lesson). Matched by class name so the check needs no hard dependency.
61
+ RESPONSE_TRANSFORMING_MIDDLEWARE = %w[Rack::Deflater].freeze
62
+
63
+ # Statuses that do NOT fail the run. Anything else (including a malformed /
64
+ # future status) is treated as a failure (review finding R1).
65
+ SUCCESS_STATUSES = %i[pass warn].freeze
66
+
67
+ # @param serialize_only_root [String] directory whose `**/*.rb` is scanned
68
+ # for the serialize-only tripwire. Defaults to the gem's own `lib/`;
69
+ # injectable so specs can point it at a fixture tree.
70
+ def initialize(serialize_only_root: File.join(Ruact.gem_path, "lib"))
71
+ @serialize_only_root = serialize_only_root
72
+ end
73
+
74
+ # Runs all checks, prints results, returns true if none FAIL.
21
75
  def self.run
22
76
  new.run
23
77
  end
@@ -26,7 +80,10 @@ module Ruact
26
80
  puts "[ruact] Health check"
27
81
  results = CHECKS.map { |check| send(:"check_#{check}") }
28
82
  results.each { |status, message| puts format_result(status, message) }
29
- passed = results.all? { |status, _| status == :pass }
83
+ # A :warn must NOT fail the run (Story 13.1 AC3); only :pass / :warn are
84
+ # success. An unexpected status (rendered `✗`) fails loudly rather than
85
+ # being silently treated as a pass (review finding R1).
86
+ passed = results.all? { |status, _| SUCCESS_STATUSES.include?(status) }
30
87
  puts "Run rails generate ruact:install to fix configuration issues" unless passed
31
88
  passed
32
89
  end
@@ -96,6 +153,74 @@ module Ruact
96
153
  "See #{RENAME_DOC_URL}."]
97
154
  end
98
155
 
156
+ # Story 13.1 (AC2) — fails when ruact's OWN Ruby source introduces an
157
+ # inbound Flight-deserialization entry point that is not explicitly
158
+ # annotated `# ruact:allow-flight-deserialization <reason>`. Passes silently
159
+ # when none exists (the current tree). Scans `@serialize_only_root/**/*.rb`,
160
+ # excluding this file and the generators' client-side templates.
161
+ def check_serialize_only
162
+ offenses = Dir[File.join(@serialize_only_root, "**", "*.rb")].flat_map do |file|
163
+ next [] if File.expand_path(file) == DOCTOR_FILE
164
+ next [] if file.match?(%r{/generators/.+/templates/})
165
+
166
+ File.foreach(file).with_index(1).filter_map do |line, lineno|
167
+ next unless DESERIALIZE_SIGNAL_RE.match?(line)
168
+ next if line.include?(ALLOW_FLIGHT_DESERIALIZATION)
169
+
170
+ "#{file}:#{lineno}"
171
+ end
172
+ end
173
+
174
+ if offenses.empty?
175
+ return [:pass, "Serialize-only invariant holds — no inbound Flight deserializer in ruact's Ruby source"]
176
+ end
177
+
178
+ [:fail,
179
+ "Inbound Flight deserializer entry point found in #{offenses.length} location(s) " \
180
+ "(first: #{offenses.first}). ruact is serialize-only: it may emit `text/x-component` " \
181
+ "but must never deserialize externally-supplied Flight into live Ruby objects " \
182
+ "(React2Shell / CVE-2025-55182 class). Remove it, or — if deliberate and reviewed — " \
183
+ "annotate the line with `#{ALLOW_FLIGHT_DESERIALIZATION} <reason>`. See #{SERIALIZE_ONLY_DOC}."]
184
+ end
185
+
186
+ # Story 13.1 (AC3) — WARNS (never fails) when a response-transforming
187
+ # middleware is mounted in the app's stack, since it may recompress/mutate a
188
+ # streamed `text/x-component` Flight body and break the wire contract.
189
+ def check_flight_middleware
190
+ stack = flight_middleware_stack
191
+ return [:pass, "No response-transforming middleware on the Flight wire path"] if stack.nil?
192
+
193
+ present = stack.filter_map { |mw| middleware_name(mw) }
194
+ .select { |name| RESPONSE_TRANSFORMING_MIDDLEWARE.include?(name) }
195
+ .uniq
196
+ return [:pass, "No response-transforming middleware on the Flight wire path"] if present.empty?
197
+
198
+ [:warn,
199
+ "#{present.join(', ')} is mounted and may transform `text/x-component` (Flight) responses, " \
200
+ "breaking the wire contract / streaming. Exclude Flight responses from compression " \
201
+ "(don't compress `text/x-component`) or mount it so it does not wrap the Flight routes."]
202
+ end
203
+
204
+ # Returns the app middleware stack to scan, or nil when unavailable (no
205
+ # Rails application present — e.g. the non-Rails / full-stub edge context).
206
+ # At real `rails ruact:doctor` time the `:environment` task has booted the
207
+ # app, so `app.middleware` is the enumerable `ActionDispatch::MiddlewareStack`.
208
+ # Before `initialize!` it is a `Rails::Configuration::MiddlewareStackProxy`
209
+ # (not enumerable) — skip it rather than crash on `filter_map`.
210
+ def flight_middleware_stack
211
+ return nil unless defined?(Rails) && Rails.respond_to?(:application)
212
+
213
+ app = Rails.application
214
+ return nil unless app.respond_to?(:middleware)
215
+
216
+ stack = app.middleware
217
+ stack.respond_to?(:each) ? stack : nil
218
+ end
219
+
220
+ def middleware_name(middleware)
221
+ middleware.respond_to?(:name) ? middleware.name : middleware.to_s
222
+ end
223
+
99
224
  def streaming_server_hint
100
225
  return "Puma" if defined?(::Puma)
101
226
  return "Unicorn" if defined?(::Unicorn)
@@ -110,7 +235,11 @@ module Ruact
110
235
  end
111
236
 
112
237
  def format_result(status, message)
113
- status == :pass ? "✓ #{message}" : "✗ #{message}"
238
+ case status
239
+ when :pass then "✓ #{message}"
240
+ when :warn then "⚠ #{message}"
241
+ else "✗ #{message}"
242
+ end
114
243
  end
115
244
  end
116
245
  end
@@ -35,11 +35,22 @@ module Ruact
35
35
 
36
36
  # Transform ERB source, replacing component tags with ERB placeholders.
37
37
  # Returns the transformed source string.
38
- def self.transform(source)
39
- new.transform(source)
38
+ #
39
+ # +identifier+ is the template path (forwarded by {ErbPreprocessorHook} as
40
+ # +template.identifier+) so a Story 13.5 contract violation can name the
41
+ # call site's file:line. +registry+ is the component contract source — an
42
+ # injectable seam (Story 7.1 explicit-context grain); it defaults to the
43
+ # process-loaded {Ruact.manifest}. Pass +registry: nil+ (or a stub) in
44
+ # specs to control contract lookup; +nil+ forces fail-open (no validation).
45
+ def self.transform(source, identifier: nil, registry: :default)
46
+ new.transform(source, identifier: identifier, registry: registry)
40
47
  end
41
48
 
42
- def transform(source)
49
+ def transform(source, identifier: nil, registry: :default)
50
+ # NOTE: +registry+ stays the +:default+ sentinel here. It is resolved to
51
+ # +Ruact.manifest+ LAZILY, only inside the component-tag block below — a
52
+ # source with no PascalCase tags must touch the registry not at all
53
+ # (AC2/AC6 fast-path invariant).
43
54
  # Step 1: transform <Suspense> paired tags into <ruact-suspense> HTML elements.
44
55
  # This runs before the general component regex so Suspense isn't treated as a component.
45
56
  result = source
@@ -47,7 +58,11 @@ module Ruact
47
58
  attrs = ::Regexp.last_match(1)
48
59
  fallback = extract_string_attr(attrs, "fallback") || ""
49
60
  escaped = fallback.gsub('"', "&quot;")
50
- %(<ruact-suspense data-ruact-fallback="#{escaped}">)
61
+ # Optional `delay="2.5"` — the server-side wait (seconds) before
62
+ # the deferred chunk streams. Forwarded to SuspenseElement#delay.
63
+ delay = extract_string_attr(attrs, "delay")
64
+ delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '&quot;')}") : ""
65
+ %(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
51
66
  end
52
67
  .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
53
68
 
@@ -59,9 +74,17 @@ module Ruact
59
74
  line = result[0...match_start].count("\n") + 1
60
75
 
61
76
  begin
62
- props_ruby = parse_props(attrs_string)
77
+ registry = Ruact.manifest if registry == :default # lazy — only when a tag exists
78
+ pairs = parse_prop_pairs(attrs_string)
79
+ validate_contract(registry, component_name, pairs.map(&:first),
80
+ at: { file: identifier, line: line, snippet: match.strip })
81
+ props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
63
82
  props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
64
83
  %(<%= __ruact_component__(#{component_name.inspect}, #{props_hash}) %>)
84
+ rescue ComponentContractError
85
+ # Already carries file:line + offending prop + suggestion — re-raise
86
+ # AS-IS (do NOT append the generic "at line N: snippet" tail).
87
+ raise
65
88
  rescue PreprocessorError => e
66
89
  raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
67
90
  end
@@ -77,11 +100,41 @@ module Ruact
77
100
  m&.[](1)
78
101
  end
79
102
 
80
- # Parses the attributes string of a component tag and returns a Ruby
81
- # fragment representing a Hash literal, e.g.:
82
- # "postId" => @post.id, "initialCount" => 5
83
- def parse_props(attrs_string)
84
- return "" if attrs_string.empty?
103
+ # Story 13.5 run the opt-in contract check for +component_name+ against
104
+ # the parsed call-site prop +names+. Looks the contract up through the
105
+ # injected +registry+ seam and SKIPS ENTIRELY when there is none (no
106
+ # registry, registry without +contract_for+, or no contract for this
107
+ # component) that is the AC2/AC6 fail-open path that keeps a contract-less
108
+ # component byte-identical. Invoked ONLY inside the component-tag block, so
109
+ # the no-tag fast path never reads the registry.
110
+ def validate_contract(registry, component_name, names, at:)
111
+ return unless registry.respond_to?(:contract_for)
112
+
113
+ contract = registry.contract_for(component_name, controller_path: controller_path_from(at[:file]))
114
+ return if contract.nil?
115
+
116
+ ComponentContract.validate(
117
+ component_name: component_name, prop_names: names, contract: contract, at: at
118
+ )
119
+ end
120
+
121
+ # Best-effort controller_path from a template identifier so a co-located
122
+ # component's contract resolves (e.g. ".../app/views/posts/show.html.erb"
123
+ # → "posts"). A wrong/absent guess is harmless: {ClientManifest#resolve_key}
124
+ # falls back to the shared PascalCase key.
125
+ def controller_path_from(identifier)
126
+ return nil unless identifier
127
+
128
+ m = identifier.to_s.match(%r{app/views/(.+)/[^/]+\z})
129
+ m && m[1]
130
+ end
131
+
132
+ # Parses the attributes string of a component tag into ordered
133
+ # +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["count", "5"]].
134
+ # Honors nested braces in values (via {#extract_braced_expr}). The names
135
+ # feed the Story 13.5 contract check; the pairs render the props Hash.
136
+ def parse_prop_pairs(attrs_string)
137
+ return [] if attrs_string.empty?
85
138
 
86
139
  pairs = []
87
140
  remaining = attrs_string.dup
@@ -91,13 +144,13 @@ module Ruact
91
144
  # Find the matching closing brace, respecting nesting
92
145
  value_start = m.end(0)
93
146
  value_expr = extract_braced_expr(remaining, value_start)
94
- pairs << "#{prop_name.inspect} => #{value_expr}"
147
+ pairs << [prop_name, value_expr]
95
148
  # Advance past this prop
96
149
  remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
97
150
  break if remaining.nil?
98
151
  end
99
152
 
100
- pairs.join(", ")
153
+ pairs
101
154
  end
102
155
 
103
156
  # Given a string and a start position (just after the opening '{'),
@@ -14,7 +14,10 @@ module Ruact
14
14
  # Called by ActionView for every ERB template. +source+ is the raw ERB
15
15
  # text; the return value is Ruby code that ActionView will eval.
16
16
  def call(template, source)
17
- super(template, ErbPreprocessor.transform(source))
17
+ # Story 13.5 — forward +template.identifier+ (the template file path) so a
18
+ # component-contract violation can name the call site's file:line. The
19
+ # contract registry defaults to the process-loaded +Ruact.manifest+.
20
+ super(template, ErbPreprocessor.transform(source, identifier: template.identifier))
18
21
  end
19
22
  end
20
23
  end
data/lib/ruact/errors.rb CHANGED
@@ -12,6 +12,16 @@ module Ruact
12
12
  # Raised when the ERB preprocessor encounters a malformed component tag.
13
13
  class PreprocessorError < Error; end
14
14
 
15
+ # Story 13.5 (FR100) — raised by {Ruact::ComponentContract} at ERB
16
+ # preprocess-time when a `<Component .../>` call site violates the component's
17
+ # opt-in contract: a missing required prop/slot or an unknown prop name.
18
+ # Subclasses {PreprocessorError} so it flows through the same dev error
19
+ # overlay (NFR30 lineage) and the hook treats it uniformly — but the distinct
20
+ # class lets the preprocessor re-raise it AS-IS (its message already carries
21
+ # file:line + the offending prop + a "did you mean?" suggestion) instead of
22
+ # re-wrapping it with the generic "at line N: snippet" tail.
23
+ class ComponentContractError < PreprocessorError; end
24
+
15
25
  # Raised when application code attempts to mutate Ruact::Configuration outside
16
26
  # of a Ruact.configure block. The configuration is frozen after initialization
17
27
  # to prevent runtime drift; see Story 7.3 for the rationale and the decision
@@ -26,43 +36,6 @@ module Ruact
26
36
  # buried under a Nokogiri stack. See Story 7.4 for the rationale.
27
37
  class HtmlConverterError < Error; end
28
38
 
29
- # Story 8.3 — raised by a standalone server-action block (a module that
30
- # `extend`s {Ruact::ServerAction}) when its body invokes
31
- # {Ruact::ServerFunctions::StandaloneContext#current_user} but no
32
- # {Ruact::Configuration#current_user_resolver} has been configured. The
33
- # message names both worked examples (Devise + hand-rolled session) so
34
- # the developer can wire the resolver without leaving the stack trace.
35
- class CurrentUserNotConfiguredError < Error
36
- DEFAULT_MESSAGE = "Ruact.current_user requires Ruact.config.current_user_resolver to be set. " \
37
- "Example (Devise): Ruact.configure { |c| c.current_user_resolver = ->(env) { env['warden']&.user } }. " \
38
- "Example (hand-rolled session): Ruact.configure { |c| c.current_user_resolver = " \
39
- "->(env) { User.find_by(id: env['rack.session'][:user_id]) } }."
40
-
41
- def initialize(message = DEFAULT_MESSAGE)
42
- super
43
- end
44
- end
45
-
46
- # Story 8.3 — raised inside a standalone server-action block to surface a
47
- # non-2xx response without calling `render` (which the StandaloneContext
48
- # does not expose). The endpoint dispatcher rescues this exception class
49
- # and renders `status` + `body` verbatim, mirroring how a controller-hosted
50
- # action would call `render(json: ..., status: ...)`.
51
- class ActionError < Error
52
- attr_reader :status, :body
53
-
54
- # @param status [Symbol, Integer] HTTP status (e.g. :unprocessable_entity, 422).
55
- # @param body [Object] the response payload. Hash/Array/scalar values are
56
- # rendered as JSON; nil renders an empty body.
57
- # @param message [String] optional message; defaults to "ruact action error
58
- # (status=<status>)" so the exception is still legible in logs.
59
- def initialize(status:, body: nil, message: nil)
60
- @status = status
61
- @body = body
62
- super(message || "ruact action error (status=#{status.inspect})")
63
- end
64
- end
65
-
66
39
  # Story 9.5 (FR88) — raised by the query dispatch controller when a
67
40
  # `useQuery` request's parameters violate the kwargs allowlist: a complex
68
41
  # value (array / object) where only `string | number | boolean | null` is
@@ -74,14 +47,27 @@ module Ruact
74
47
  # fix the call site without reading the server source.
75
48
  class BadRequestError < Error; end
76
49
 
77
- # Story 8.5 — raised by `EndpointController#__ruact_enforce_upload_limit!`
78
- # when an inbound multipart / urlencoded request's `Content-Length` exceeds
50
+ # Story 13.2 (FR96) — raised by `Ruact.locate_signed` when a SignedGlobalID
51
+ # token fails verification: a tampered signature, an expired token, or a token
52
+ # scoped to a different `for:` purpose (`GlobalID::Locator.locate_signed`
53
+ # returns `nil`). Inherits from `Ruact::Error` so the Story 8.4
54
+ # `rescue_from StandardError` chain catches it cleanly; `__ruact_status_for`
55
+ # maps it to HTTP 400 (Bad Request) — a forged/expired reference is an
56
+ # invalid credential the client supplied, NOT a missing record. This keeps
57
+ # `ActiveRecord::RecordNotFound` from leaking and never reveals whether the
58
+ # target record exists (verification fails before any DB lookup). A *valid*
59
+ # token whose record was since deleted is NOT this error — the underlying
60
+ # finder (`ActiveRecord::RecordNotFound`) propagates and is the host's normal
61
+ # not-found concern, exactly as a raw `Model.find` would be.
62
+ class InvalidSignedGlobalIDError < Error; end
63
+
64
+ # Story 8.5 — raised by the `Ruact::Server` upload guard when an inbound
65
+ # multipart / urlencoded request's `Content-Length` exceeds
79
66
  # `Ruact.config.max_upload_bytes`. The exception inherits from
80
- # `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain on
81
- # `EndpointController` catches it cleanly; the endpoint controller's
82
- # `__ruact_status_for` maps it to HTTP 413, and `ErrorPayload.build`
83
- # extracts the `received_bytes` / `limit_bytes` pair into a dev-only
84
- # `upload_limit` block alongside the four baseline keys.
67
+ # `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain catches
68
+ # it cleanly; `__ruact_status_for` maps it to HTTP 413, and
69
+ # `ErrorPayload.build` extracts the `received_bytes` / `limit_bytes` pair
70
+ # into a dev-only `upload_limit` block alongside the four baseline keys.
85
71
  #
86
72
  # The pair is exposed as `attr_reader` so the structured payload (and host
87
73
  # log lines) can show both numbers without re-parsing the message string.
@@ -201,7 +201,28 @@ module Ruact
201
201
  child_nodes = convert_children(node)
202
202
  children = child_nodes.length == 1 ? child_nodes.first : child_nodes
203
203
 
204
- Flight::SuspenseElement.new(fallback: fallback, children: children)
204
+ # Optional `delay="2.5"` from the ERB → SuspenseElement#delay (the
205
+ # server-side wait, in seconds, before the deferred chunk streams). When
206
+ # absent, blank, unparseable, or non-finite, SuspenseElement falls back to
207
+ # its default.
208
+ delay_attr = node["data-ruact-delay"]
209
+ delay = begin
210
+ if delay_attr && !delay_attr.to_s.strip.empty?
211
+ # A string overflow like "1e309" parses to Infinity (Float() does not
212
+ # raise) — guard finiteness so a non-finite delay can't reach the
213
+ # renderer's sleep(delay) and raise RangeError.
214
+ parsed = Float(delay_attr)
215
+ parsed.finite? ? parsed : nil
216
+ end
217
+ rescue ArgumentError, TypeError
218
+ nil
219
+ end
220
+
221
+ if delay
222
+ Flight::SuspenseElement.new(fallback: fallback, children: children, delay: delay)
223
+ else
224
+ Flight::SuspenseElement.new(fallback: fallback, children: children)
225
+ end
205
226
  end
206
227
 
207
228
  def convert_children(node)