ruact 0.0.7 → 0.0.8

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +114 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/ruact/controller.rb +9 -0
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +113 -0
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +2 -2
  11. data/lib/ruact/serializable.rb +98 -6
  12. data/lib/ruact/server.rb +163 -0
  13. data/lib/ruact/server_functions/introspection.rb +81 -0
  14. data/lib/ruact/server_functions.rb +26 -4
  15. data/lib/ruact/testing/component_query.rb +113 -0
  16. data/lib/ruact/testing/flight_extractor.rb +221 -0
  17. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  18. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  19. data/lib/ruact/testing.rb +90 -0
  20. data/lib/ruact/version.rb +1 -1
  21. data/lib/tasks/ruact.rake +55 -2
  22. data/spec/ruact/doctor_spec.rb +141 -0
  23. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  24. data/spec/ruact/install_generator_spec.rb +285 -0
  25. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  26. data/spec/ruact/serializable_spec.rb +126 -0
  27. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  28. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  29. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  30. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  31. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  32. data/spec/support/flight_wire_parser.rb +12 -126
  33. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  34. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  35. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  36. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  38. metadata +14 -2
@@ -28,6 +28,29 @@ module Ruact
28
28
  SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
29
29
  SUSPENSE_CLOSE_RE = %r{</Suspense>}
30
30
 
31
+ # Story 15.2 (FR106) — matches ANY PascalCase component tag: opening
32
+ # (`<Card>`), self-closing (`<Card />`), or closing (`</Card>`). Capture 1 is
33
+ # the leading slash (present only on a closing tag); capture 2 is the name.
34
+ # The loud-children detection scans these left-to-right with a stack: an
35
+ # opening pushes, a self-closing (`/>`) is ignored, and a closing that pops a
36
+ # matching opening means that opening had children (paired usage) → loud
37
+ # error. A single linear pass (no backreference/lazy backtracking) keeps the
38
+ # component-dense fast path linear regardless of how many tags go unclosed.
39
+ COMPONENT_ANY_TAG_RE = %r{<(/)?([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>}
40
+
41
+ # Cheap allocation-free probe (`String#match?`) for "is there ANY PascalCase
42
+ # closing tag at all?". The loud-children scan only matters when one exists,
43
+ # so this gates the (copy-heavy) mask/scan work off the common all-self-
44
+ # closing fast path. `</Suspense>` matches too — harmless, it gets masked.
45
+ CLOSING_TAG_PROBE_RE = %r{</[A-Z][A-Za-z0-9]*>}
46
+
47
+ # Newline-preserving mask for ERB islands (`<% … %>`, `<%= … %>`, `<%# … %>`).
48
+ # The loud-children scan blanks these first so a `</Card>` that lives inside
49
+ # Ruby/ERB string or comment text can never be mistaken for a real component
50
+ # closing tag (a valid bare `<Dialog>` opening must not error because some
51
+ # unrelated `<% "</Dialog>" %>` appears later).
52
+ ERB_ISLAND_RE = /<%.*?%>/m
53
+
31
54
  # Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
32
55
  # We use a simple bracket-depth counter approach during scanning instead of regex
33
56
  # because expressions can contain nested braces: {foo.bar({ a: 1 })}.
@@ -66,6 +89,16 @@ module Ruact
66
89
  end
67
90
  .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
68
91
 
92
+ # Step 1.5 (Story 15.2 / FR106): before the general component pass, fail
93
+ # loudly if any PascalCase component tag is used with children (a matching
94
+ # closing tag). Silent degradation of `<Card>Hello</Card>` — the #1
95
+ # predictable JSX-habit mistake — becomes a self-contained, re-raised-as-is
96
+ # PreprocessorError naming the fix. It scans the ORIGINAL +source+ (so
97
+ # file:line is exact) and masks Suspense (the one legitimate paired
98
+ # PascalCase tag) newline-for-newline, so `<Suspense>...</Suspense>` can
99
+ # never trip and every reported line matches the template verbatim.
100
+ detect_children!(source, identifier)
101
+
69
102
  # Step 2: transform remaining PascalCase self-closing / opening component tags.
70
103
  result.gsub(COMPONENT_TAG_RE) do |match|
71
104
  component_name = ::Regexp.last_match(1)
@@ -99,6 +132,86 @@ module Ruact
99
132
 
100
133
  private
101
134
 
135
+ # Story 15.2 (FR106) — raise a loud, self-contained {ChildrenNotSupportedError}
136
+ # on the FIRST PascalCase component tag used with children (a matching closing
137
+ # tag). +source+ is the ORIGINAL template text; +identifier+ is the template
138
+ # path (from {ErbPreprocessorHook}). Suspense — the one legitimate paired
139
+ # PascalCase tag — is masked newline-for-newline first, so it never trips AND
140
+ # every byte position (hence every reported line) still lines up with the raw
141
+ # template. The line uses the same idiom as Step 2 (`count("\n") + 1`) on the
142
+ # OPENING tag's offset. Message mirrors {ComponentContract.raise_error} shape
143
+ # so both loud preprocess errors read identically. A no-op when no pair is
144
+ # present — the fast path stays byte-identical.
145
+ def detect_children!(source, identifier)
146
+ # Fast path: a children pair REQUIRES a literal PascalCase closing tag, so
147
+ # a source without one (the common all-self-closing case) can never trip —
148
+ # bail before allocating anything. `match?` builds no MatchData, and this
149
+ # skips the mask/scan copies entirely, keeping the hot render/preprocess
150
+ # path's allocation profile flat (the benchmark renders only self-closing
151
+ # components, so it must stay at baseline).
152
+ return unless source.match?(CLOSING_TAG_PROBE_RE)
153
+
154
+ # ERB islands first, then Suspense — both blank their text newline-for-
155
+ # newline so byte offsets (hence reported lines) still match the raw
156
+ # template, while neither ERB string text nor the legitimate Suspense pair
157
+ # can be seen by the tag scan.
158
+ scan = mask_suspense(mask_erb(source))
159
+ # PER-NAME open stacks (name → [offsets]) so a closing tag checks for a
160
+ # matching open in O(1) via `open_ats[name].last`, keeping the whole scan
161
+ # linear even under thousands of stray/unmatched PascalCase closing tags
162
+ # (a global stack + `rindex` was quadratic — Codex Round 3).
163
+ open_ats = Hash.new { |h, k| h[k] = [] }
164
+
165
+ scan.scan(COMPONENT_ANY_TAG_RE) do
166
+ m = ::Regexp.last_match
167
+ name = m[2]
168
+
169
+ if m[1] # a closing tag `</Name>`
170
+ at = open_ats[name].last
171
+ next unless at # stray close with no open → literal text, ignore
172
+
173
+ raise_children_error(name, identifier, scan, at)
174
+ elsif m[0].end_with?("/>") # self-closing → carries no children
175
+ next
176
+ else # an opening tag `<Name ...>` — record the NEAREST open of this name
177
+ open_ats[name] << m.begin(0)
178
+ end
179
+ end
180
+
181
+ nil
182
+ end
183
+
184
+ # Raise the self-contained {ChildrenNotSupportedError}. +at+ is the OPENING
185
+ # tag's byte offset in +scan+ (position-faithful to the raw source), so the
186
+ # line uses the same idiom as Step 2. Message mirrors
187
+ # {ComponentContract.raise_error} so both loud preprocess errors read alike.
188
+ def raise_children_error(component, identifier, scan, at)
189
+ line = scan[0...at].count("\n") + 1
190
+ location = [identifier, line].compact.join(":")
191
+ location = "(unknown location)" if location.empty?
192
+ raise ChildrenNotSupportedError,
193
+ "ruact: <#{component}> at #{location} children are not supported " \
194
+ "— pass content as a prop, e.g. `<#{component} content={...} />`."
195
+ end
196
+
197
+ # Blank out Suspense open/close tags (the one legitimate paired PascalCase
198
+ # tag) while preserving EVERY newline and byte offset, so the loud-children
199
+ # scan neither trips on `<Suspense>...</Suspense>` nor mis-reports a line when
200
+ # a multi-line Suspense opening precedes the offending tag. Non-newline chars
201
+ # → spaces (same length); newlines kept verbatim.
202
+ def mask_suspense(source)
203
+ source
204
+ .gsub(SUSPENSE_OPEN_RE) { |m| m.gsub(/[^\n]/, " ") }
205
+ .gsub(SUSPENSE_CLOSE_RE) { |m| m.gsub(/[^\n]/, " ") }
206
+ end
207
+
208
+ # Blank ERB islands (position-faithful, see {mask_suspense}) so component
209
+ # tags that appear only inside Ruby/ERB string or comment text are invisible
210
+ # to the loud-children tag scan.
211
+ def mask_erb(source)
212
+ source.gsub(ERB_ISLAND_RE) { |m| m.gsub(/[^\n]/, " ") }
213
+ end
214
+
102
215
  # Extract a string attribute value (double or single quoted) from an attrs string.
103
216
  def extract_string_attr(attrs, name)
104
217
  m = attrs.match(/\b#{Regexp.escape(name)}\s*=\s*"([^"]*)"/) ||
data/lib/ruact/errors.rb CHANGED
@@ -22,6 +22,22 @@ module Ruact
22
22
  # re-wrapping it with the generic "at line N: snippet" tail.
23
23
  class ComponentContractError < PreprocessorError; end
24
24
 
25
+ # Story 15.2 (FR106) — raised by {Ruact::ErbPreprocessor} at preprocess time
26
+ # when a PascalCase component tag is used with children (a matching closing
27
+ # tag, e.g. `<Card>Hello</Card>`). ruact component tags are self-closing only:
28
+ # a component receives a props Hash, never a children element tree. This is the
29
+ # #1 predictable JSX-habit mistake, and it used to degrade silently (the
30
+ # children leaked into the surrounding HTML while the component rendered with
31
+ # none). Subclasses {PreprocessorError} so it flows through the same dev error
32
+ # overlay (NFR30 lineage) and the hook treats it uniformly — but the distinct
33
+ # class (mirroring {ComponentContractError}) lets the preprocessor re-raise it
34
+ # AS-IS (its message already carries the component name + file:line + the exact
35
+ # fix) and lets the 15.7 capstone assert this trap fails loudly by class. The
36
+ # sole legitimate paired PascalCase tag, `<Suspense>...</Suspense>`, is
37
+ # normalized to `<ruact-suspense>` in Step 1 before this detection runs, so it
38
+ # never trips.
39
+ class ChildrenNotSupportedError < PreprocessorError; end
40
+
25
41
  # Raised when application code attempts to mutate Ruact::Configuration outside
26
42
  # of a Ruact.configure block. The configuration is frozen after initialization
27
43
  # to prevent runtime drift; see Story 7.3 for the rationale and the decision
@@ -79,8 +79,8 @@ module Ruact
79
79
  return nil if soft
80
80
 
81
81
  raise ManifestError, <<~MSG.strip
82
- [ruact] Vite dev server inacessível em #{base_url} e nenhum \
83
- react-client-manifest.json encontrado em #{file_path} — rode `bin/dev`.
82
+ [ruact] Vite dev server unreachable at #{base_url} and no \
83
+ react-client-manifest.json found at #{file_path} — run `bin/dev`.
84
84
  MSG
85
85
  end
86
86
 
@@ -5,32 +5,71 @@ module Ruact
5
5
  # client component. Declare which attributes are safe to serialize with
6
6
  # +ruact_props+; only those attributes will be included in the wire payload.
7
7
  #
8
- # @example
8
+ # Works on POROs and on ActiveRecord models alike. For a PORO the loud check
9
+ # fires at class-load; for an ActiveRecord model (whose attribute readers are
10
+ # defined lazily) the same loud check is deferred to the first
11
+ # +ruact_serialize+ — see {ClassMethods#ruact_props}.
12
+ #
13
+ # @example PORO
9
14
  # class Post
10
15
  # include Ruact::Serializable
11
16
  # attr_reader :id, :title, :secret
12
17
  # ruact_props :id, :title # :secret is never sent to the client
13
18
  # end
19
+ #
20
+ # @example ActiveRecord model
21
+ # class Post < ApplicationRecord
22
+ # include Ruact::Serializable
23
+ # ruact_props :id, :title # other columns never cross to the client
24
+ # end
14
25
  module Serializable
15
26
  def self.included(base)
16
27
  base.extend(ClassMethods)
17
28
  base.instance_variable_set(:@ruact_props, [])
29
+ base.instance_variable_set(:@ruact_deferred_props, [])
18
30
  end
19
31
 
20
32
  module ClassMethods
21
33
  # Declare which instance methods should be included in the serialized
22
- # payload. Raises +ArgumentError+ at class-load time if any name has no
23
- # corresponding method defined on the class.
34
+ # payload.
35
+ #
36
+ # The loud-omission guarantee is preserved: a name with no corresponding
37
+ # method still raises a clean +ArgumentError+. Only the *timing* of that
38
+ # check depends on the class:
39
+ #
40
+ # * **PORO** — checked eagerly at class-load (macro-invocation) time, as
41
+ # before. A typo'd/absent prop raises immediately.
42
+ # * **ActiveRecord model** — ActiveRecord defines its attribute reader
43
+ # methods lazily (on first instance access), so at macro-invocation time
44
+ # +method_defined?(:title)+ is +false+ even for a real column. Checking
45
+ # eagerly would either reject a valid model at boot or require a live DB
46
+ # connection at class-load (a Rails anti-pattern). So for a lazy-attribute
47
+ # class the not-yet-defined names are recorded and their loud check is
48
+ # deferred to the first +ruact_serialize+ (via +respond_to?+ on the
49
+ # instance), where the DB is up. A genuine typo still raises the same
50
+ # clean +ArgumentError+ — just at first render of that model, not at boot.
24
51
  #
25
52
  # @param attrs [Array<Symbol>]
53
+ # @raise [ArgumentError] immediately for an undefined prop on a PORO; at
54
+ # first +ruact_serialize+ for an undefined prop on an ActiveRecord model.
26
55
  def ruact_props(*attrs)
56
+ deferred = []
27
57
  attrs.each do |attr|
28
- unless method_defined?(attr)
29
- raise ArgumentError,
30
- "ruact_props: method `#{attr}` is not defined on #{self}"
58
+ next if method_defined?(attr)
59
+
60
+ # Lazy-attribute (ActiveRecord) class: the reader may still appear on
61
+ # first instance access. Record it and check loudly on first serialize
62
+ # instead of failing a valid model at boot.
63
+ if ruact_lazy_attribute_class?
64
+ deferred << attr
65
+ next
31
66
  end
67
+
68
+ raise ArgumentError,
69
+ "ruact_props: method `#{attr}` is not defined on #{self}"
32
70
  end
33
71
  @ruact_props = attrs
72
+ @ruact_deferred_props = deferred
34
73
  end
35
74
 
36
75
  # Returns the list of declared prop names as symbols.
@@ -46,12 +85,65 @@ module Ruact
46
85
  end
47
86
  []
48
87
  end
88
+
89
+ # Names whose eager loud check was deferred to first serialize (lazy
90
+ # ActiveRecord attributes). Read from the same class that declared the
91
+ # props, so subclasses share the parent declaration.
92
+ #
93
+ # @return [Array<Symbol>]
94
+ def ruact_deferred_props_list
95
+ klass = self
96
+ while klass
97
+ if klass.instance_variable_defined?(:@ruact_props)
98
+ return klass.instance_variable_get(:@ruact_deferred_props) || []
99
+ end
100
+
101
+ klass = klass.superclass
102
+ end
103
+ []
104
+ end
105
+
106
+ # Run the deferred loud check once, on first +ruact_serialize+. A recorded
107
+ # name that the instance does not +respond_to?+ (a typo, or a genuinely
108
+ # absent column) raises the same clean +ArgumentError+ the eager path would.
109
+ #
110
+ # Memoization keys on the *validated deferred list itself* (not a bare
111
+ # boolean), so any change to the effective declaration — a re-declaration
112
+ # on this class OR on an ancestor whose props a subclass inherits — is
113
+ # detected and re-validated loudly on the next serialize. Once a given
114
+ # list has been validated it costs one array comparison per serialize.
115
+ #
116
+ # @param instance [Object]
117
+ # @raise [ArgumentError]
118
+ def ruact_validate_deferred_props!(instance)
119
+ deferred = ruact_deferred_props_list
120
+ return if @ruact_deferred_props_validated == deferred
121
+
122
+ deferred.each do |attr|
123
+ next if instance.respond_to?(attr)
124
+
125
+ raise ArgumentError,
126
+ "ruact_props: method `#{attr}` is not defined on #{self}"
127
+ end
128
+ @ruact_deferred_props_validated = deferred
129
+ end
130
+
131
+ # True when this class defines its attribute reader methods lazily, i.e.
132
+ # an ActiveRecord model. Detected WITHOUT a hard ActiveRecord dependency
133
+ # (the gem stays single-dep +nokogiri+): the constant is only referenced
134
+ # when it is already defined in the host.
135
+ #
136
+ # @return [Boolean]
137
+ def ruact_lazy_attribute_class?
138
+ !!(defined?(ActiveRecord::Base) && self < ActiveRecord::Base)
139
+ end
49
140
  end
50
141
 
51
142
  # Serialize only the attributes declared with +ruact_props+.
52
143
  #
53
144
  # @return [Hash{String => Object}]
54
145
  def ruact_serialize
146
+ self.class.ruact_validate_deferred_props!(self)
55
147
  self.class.ruact_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
56
148
  end
57
149
  end
data/lib/ruact/server.rb CHANGED
@@ -104,6 +104,28 @@ module Ruact
104
104
  before_action :__ruact_set_vary_on_accept!
105
105
  after_action :__ruact_set_vary_on_accept!
106
106
 
107
+ # Story 15.0 (F6) — dev-only legibility warning: an action that registers
108
+ # validation errors (`ruact_errors(record)`) and then performs an EXPLICIT
109
+ # render on a function-call request bypasses `default_render`, so the
110
+ # registered errors silently vanish from the JSON body. The warning names
111
+ # the opt-out; it never fires on the documented-correct patterns (Bucket-1
112
+ # page render, redirect on either bucket, the fall-through itself, or an
113
+ # action that never touches the collector). Log-only — bodies and statuses
114
+ # are byte-identical in every environment.
115
+ after_action :__ruact_warn_errors_injection_opt_out!
116
+
117
+ # Story 15.5 (FR109) — dev-only response-shape legibility log: one
118
+ # `[ruact]` line per ruact-NEGOTIATED request naming the chosen bucket +
119
+ # the deciding signal (e.g. `Accept: application/json + POST →
120
+ # function-call JSON`), so the dual-bucket negotiation is observable at
121
+ # runtime without a debugger. Adjacent to the F6 warning above so the two
122
+ # dev-only legibility hooks read as a pair. Same dev gate
123
+ # (`__ruact_development?`), same log-only invariant — bodies, statuses,
124
+ # and headers are byte-identical in every environment. SILENT outside
125
+ # development and on a plain Rails action on the same controller (a
126
+ # response ruact did not negotiate).
127
+ after_action :__ruact_log_response_shape!
128
+
107
129
  # Story 8.4 salvage — same registration order as v1 (Pitfall #1): the
108
130
  # generic StandardError entry first, the explicit
109
131
  # InvalidAuthenticityToken entry second so it wins Rails'
@@ -201,6 +223,12 @@ module Ruact
201
223
  # one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
202
224
  assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
203
225
 
226
+ # Story 15.0 (F6) — reaching this branch means ruact itself is producing
227
+ # the function-call response (fall-through: injection, plain JSON, or
228
+ # 204), so the opt-out warning must stay silent. Set AFTER `view_assigns`
229
+ # is captured above so the flag ivar never leaks into the serialized body.
230
+ @__ruact_function_response_owned = true
231
+
204
232
  # Story 13.3 (FR98, AC3) — when the host opted into the validation-error
205
233
  # round-trip (`ruact_errors(@record)` was called this request), inject the
206
234
  # collected errors under the reserved JSON key `"errors"` alongside the
@@ -249,6 +277,11 @@ module Ruact
249
277
  _ensure_url_is_http_header_safe(location)
250
278
  location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)
251
279
 
280
+ # Story 15.0 (F6) — a Bucket-2 `redirect_to` is a ruact-owned response
281
+ # (`$redirect`; registered errors ride flash), not an injection opt-out.
282
+ @__ruact_function_response_owned = true
283
+ # Story 15.5 (FR109) — mark the `$redirect` sub-shape for the dev log.
284
+ @__ruact_function_redirect = true
252
285
  render json: { "$redirect" => __ruact_redirect_path(location) }
253
286
  end
254
287
 
@@ -290,6 +323,136 @@ module Ruact
290
323
  url
291
324
  end
292
325
 
326
+ # Story 15.0 (F6) — the dev-only opt-out warning. Fires when ALL hold:
327
+ # development environment, the request is a function call
328
+ # ({#__ruact_function_call?}), the collector was touched
329
+ # (`ruact_errors(record)` ran this request), and the response was produced
330
+ # by an EXPLICIT host render — i.e. neither {#default_render}'s
331
+ # function-call branch nor the Bucket-2 {#redirect_to} set
332
+ # `@__ruact_function_response_owned`. In that shape the registered errors
333
+ # were silently dropped from the JSON body (the F6 finding).
334
+ #
335
+ # The four MUST-NOT-warn guardrails fall out of the predicate:
336
+ # (a) Bucket-1 `render :new` — not a function call;
337
+ # (b) `redirect_to` on either bucket — Bucket-2 sets the owned flag,
338
+ # Bucket-1 is not a function call;
339
+ # (c) the fall-through — `default_render` sets the owned flag;
340
+ # (d) an untouched collector — `__ruact_errors_touched?` is false.
341
+ # The exception path never reaches here either: a raise aborts the
342
+ # after_action chain before this hook runs (`rescue_from` renders outside
343
+ # it), so a structured error payload is not misread as an opt-out.
344
+ def __ruact_warn_errors_injection_opt_out!
345
+ return unless __ruact_development?
346
+ return unless __ruact_function_call?
347
+ return unless __ruact_errors_touched?
348
+ return if @__ruact_function_response_owned
349
+
350
+ Rails.logger&.warn(
351
+ "[ruact] #{self.class.name}##{action_name} called `ruact_errors` and then rendered " \
352
+ "explicitly on a function-call request — the registered validation errors were NOT " \
353
+ "injected into the JSON response. Remove the explicit render and let the action fall " \
354
+ "through (ruact injects `errors` into the body), or surface them on a page render with " \
355
+ "`errors={ruact_errors}` / carry them through a `redirect_to` (flash)."
356
+ )
357
+ end
358
+
359
+ # Story 15.5 (FR109) — the dev-only response-shape log. Emits exactly one
360
+ # `[ruact]` line per ruact-negotiated request naming the chosen bucket and
361
+ # the deciding signal (Accept + verb), mirroring the vocabulary of the
362
+ # canonical caller→shape table in `server-actions.md`. Log-only: the
363
+ # response body, status, and headers are byte-identical to before in every
364
+ # environment.
365
+ #
366
+ # Coverage (D1, `Ruact::Server`-only for 15.5): the function-call-JSON
367
+ # bucket (incl. the `204` and `$redirect` sub-shapes) plus, for a page
368
+ # render on a controller that also includes {Ruact::Controller}, the
369
+ # Flight-page and HTML-shell shapes. A GET query (separate dispatch
370
+ # controller) and a page-only controller (includes `Ruact::Controller` but
371
+ # not `Ruact::Server`) are NOT covered — the table documents those shapes;
372
+ # extending the log to them is deferred (see deferred-work.md).
373
+ #
374
+ # SILENT (returns nil message) outside development and on a plain Rails
375
+ # action on the same controller — a response ruact did not negotiate
376
+ # (no function-call Accept and no ruact page render). A raise / before_action
377
+ # halt that skips the after_action chain is an accepted, documented log gap
378
+ # (identical to the F6 warning).
379
+ def __ruact_log_response_shape!
380
+ return unless __ruact_development?
381
+
382
+ message = __ruact_response_shape_log_message
383
+ return unless message
384
+
385
+ Rails.logger&.info(message)
386
+ end
387
+
388
+ # Classify the negotiated response shape from the request signals
389
+ # (`__ruact_function_call?`, Accept, verb) and the ruact-owned outcome flags
390
+ # (`@__ruact_function_redirect` set by the Bucket-2 {#redirect_to};
391
+ # `@__ruact_negotiated_page` set by {Ruact::Controller#emit_ruact_response} /
392
+ # its Flight `redirect_to`). Returns nil when ruact did not negotiate the
393
+ # response, so the log stays silent on plain Rails actions.
394
+ #
395
+ # @return [String, nil] the `[ruact] Ctrl#action — <signal> → <bucket>`
396
+ # line, or nil for a non-negotiated response.
397
+ def __ruact_response_shape_log_message
398
+ shape, signal = __ruact_negotiated_shape_and_signal
399
+ return unless shape
400
+
401
+ "[ruact] #{self.class.name}##{action_name} — #{signal} → #{shape}"
402
+ end
403
+
404
+ # @return [Array(String, String), nil] `[bucket, deciding_signal]` for a
405
+ # ruact-negotiated response, or nil for one ruact did not own.
406
+ def __ruact_negotiated_shape_and_signal
407
+ if __ruact_function_call?
408
+ [__ruact_function_call_shape, "Accept: application/json + #{request.request_method}"]
409
+ else
410
+ __ruact_page_shape_and_signal
411
+ end
412
+ end
413
+
414
+ # The function-call (Bucket-2) sub-shape, distinguished by the ruact-owned
415
+ # outcome: a `$redirect` directive, a `204 No Content` (no exposed ivars),
416
+ # or the ordinary serialized-ivars JSON.
417
+ def __ruact_function_call_shape
418
+ return "function-call $redirect" if @__ruact_function_redirect
419
+ return "function-call JSON (204, no ivars)" if response.status == 204
420
+
421
+ "function-call JSON"
422
+ end
423
+
424
+ # The page-render (Bucket-1 / HTML-shell) shape, keyed on the flag
425
+ # {Ruact::Controller#emit_ruact_response} set when ruact rendered the page.
426
+ # nil for a plain Rails render (the flag was never set) → the log stays
427
+ # silent on a non-negotiated response.
428
+ def __ruact_page_shape_and_signal
429
+ case @__ruact_negotiated_page
430
+ when :flight
431
+ ["Flight page", __ruact_flight_signal]
432
+ when :flight_redirect
433
+ ["Flight redirect", __ruact_flight_signal]
434
+ when :html_shell
435
+ ["HTML shell", "HTML-acceptable Accept, no Flight header"]
436
+ end
437
+ end
438
+
439
+ # The Flight deciding signal, faithful to how the request announced itself:
440
+ # the `Accept: text/x-component` header, or the `Ruact-Request: 1` header.
441
+ def __ruact_flight_signal
442
+ if request.headers["Accept"].to_s.include?("text/x-component")
443
+ "Accept: text/x-component"
444
+ else
445
+ "Ruact-Request: 1"
446
+ end
447
+ end
448
+
449
+ # @return [Boolean] true only in a real Rails development environment
450
+ # (mirrors {Ruact::ManifestResolver.development?}).
451
+ def __ruact_development?
452
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?) &&
453
+ Rails.env.development?
454
+ end
455
+
293
456
  # Raw discriminator — does the request's `Accept` header equal
294
457
  # `application/json`? This is exactly what the 8.1 runtime sends on every
295
458
  # `_makeRef` fetch (Bucket 2 — imperative `await createPost(...)`).
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # Story 15.3 (FR107) — the machine-readable `ruact:routes --json` document:
6
+ # a read-only, side-effect-free view of the accessor/route table for headless
7
+ # agents and CI gates. Reuses the SAME collectors codegen consumes (via
8
+ # {Ruact::ServerFunctions.introspect}), so the introspection can never fork a
9
+ # parallel derivation and never writes the codegen bridge / TS module.
10
+ #
11
+ # ## Shape
12
+ #
13
+ # { "schema_version" => Integer,
14
+ # "accessors" => [
15
+ # { "accessor" => String, # the JS identifier (js_identifier)
16
+ # "kind" => String, # "action" | "query"
17
+ # "verb" => String, # HTTP method
18
+ # "path" => String, # cleaned route path
19
+ # "segments" => Array<String>,
20
+ # "params" => [{ "name" => String, "required" => Boolean }] } ] }
21
+ #
22
+ # Queries carry their declared kwargs as `params` (Story 13.4); actions carry
23
+ # their required path segments, uniformly shaped as `{ name, required: true }`.
24
+ # Per Story 15.3 decision D3 there is NO per-accessor component-contract link
25
+ # (accessors are server functions, not components) — the table reports
26
+ # accessors faithfully and omits the "target component contract" clause.
27
+ module Introspection
28
+ # Version of the `ruact:routes --json` document. **EXPERIMENTAL / UNSTABLE**:
29
+ # `0` signals the shape may change without a major version bump while the
30
+ # agent-facing surface is iterated — gate any parser on it. Distinct from
31
+ # {Ruact::Doctor::SCHEMA_VERSION} (a separate, independently-versioned
32
+ # document) and from {Snapshot::VERSION_V2} (the internal codegen bridge).
33
+ SCHEMA_VERSION = 0
34
+
35
+ class << self
36
+ # Builds the `ruact:routes --json` document from +route_set+. The resolver
37
+ # callables default to real constant resolution; specs inject lambdas.
38
+ #
39
+ # @param route_set [#routes] the Rails route set.
40
+ # @param host_predicate [#call, nil] forwarded to {ServerFunctions.introspect}.
41
+ # @param overrides_for [#call, nil] forwarded to {ServerFunctions.introspect}.
42
+ # @param query_class_for [#call, nil] forwarded to {ServerFunctions.introspect}.
43
+ # @return [Hash] the schema-versioned introspection document.
44
+ # @raise [Ruact::ConfigurationError] on any naming collision.
45
+ def as_json(route_set:, host_predicate: nil, overrides_for: nil, query_class_for: nil)
46
+ entries = ServerFunctions.introspect(
47
+ route_set: route_set,
48
+ host_predicate: host_predicate,
49
+ overrides_for: overrides_for,
50
+ query_class_for: query_class_for
51
+ )
52
+ {
53
+ "schema_version" => SCHEMA_VERSION,
54
+ "accessors" => entries.map { |entry| shape(entry) }
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def shape(entry)
61
+ {
62
+ "accessor" => entry["js_identifier"],
63
+ "kind" => entry["kind"],
64
+ "verb" => entry["http_method"],
65
+ "path" => entry["path"],
66
+ "segments" => entry["segments"],
67
+ "params" => params_for(entry)
68
+ }
69
+ end
70
+
71
+ # Queries declare their params as kwargs (Story 13.4 → `params`); actions
72
+ # derive them from required path segments. Uniform `{ name, required }`.
73
+ def params_for(entry)
74
+ return entry["params"] if entry["kind"] == "query"
75
+
76
+ entry["segments"].map { |segment| { "name" => segment, "required" => true } }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -29,6 +29,7 @@ module Ruact
29
29
  autoload :ValidationErrors, "ruact/server_functions/validation_errors"
30
30
  autoload :QueryContext, "ruact/server_functions/query_context"
31
31
  autoload :QueryDispatch, "ruact/server_functions/query_dispatch"
32
+ autoload :Introspection, "ruact/server_functions/introspection"
32
33
 
33
34
  # Story 9.3 / 9.9 — orchestrates the route-driven (v2) codegen. Reads the
34
35
  # route table via {RouteSource} + {QuerySource}, writes the version-2 bridge
@@ -55,10 +56,7 @@ module Ruact
55
56
  # `Rails.logger` when Rails is loaded, else nil.
56
57
  # @return [Array<Hash>] the exposed v2 entries (actions + queries).
57
58
  def self.write_v2_snapshot!(route_set:, root:, logger: default_logger)
58
- actions = RouteSource.collect(route_set)
59
- queries = QuerySource.collect(route_set)
60
- entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
61
- detect_merged_namespace_collisions!(entries)
59
+ entries = introspect(route_set: route_set)
62
60
 
63
61
  json_path = root.join("tmp/cache/ruact/server-functions.json")
64
62
  ts_path = root.join("app/javascript/.ruact/server-functions.ts")
@@ -75,6 +73,30 @@ module Ruact
75
73
  entries
76
74
  end
77
75
 
76
+ # Story 15.3 (FR107) — the read-only, side-effect-free combine point:
77
+ # collect mutation actions ({RouteSource}) + read queries ({QuerySource}),
78
+ # sort by `js_identifier`, and run the merged-namespace collision check —
79
+ # WITHOUT writing the bridge/TS. Both {.write_v2_snapshot!} (codegen) and
80
+ # {Ruact::ServerFunctions::Introspection} (`ruact:routes --json`) call THIS,
81
+ # so what an agent verifies against can never drift from what codegen emits
82
+ # (single source of truth), and a CI gate can introspect without mutating the
83
+ # tree. The resolver callables default to real constant resolution; specs
84
+ # inject lambdas to exercise the derivation without booting controllers.
85
+ #
86
+ # @param route_set [#routes] the Rails route set.
87
+ # @param host_predicate [#call, nil] forwarded to {RouteSource.collect}.
88
+ # @param overrides_for [#call, nil] forwarded to {RouteSource.collect}.
89
+ # @param query_class_for [#call, nil] forwarded to {QuerySource.collect}.
90
+ # @return [Array<Hash>] merged action + query entries (string keys), sorted.
91
+ # @raise [Ruact::ConfigurationError] on any naming collision.
92
+ def self.introspect(route_set:, host_predicate: nil, overrides_for: nil, query_class_for: nil)
93
+ actions = RouteSource.collect(route_set, host_predicate: host_predicate, overrides_for: overrides_for)
94
+ queries = QuerySource.collect(route_set, query_class_for: query_class_for)
95
+ entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
96
+ detect_merged_namespace_collisions!(entries)
97
+ entries
98
+ end
99
+
78
100
  def self.default_logger
79
101
  defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
80
102
  end