ruact 0.0.4 → 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 (132) 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 +68 -17
  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 +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -5,302 +5,61 @@ require "tmpdir"
5
5
 
6
6
  module Ruact
7
7
  module ServerFunctions
8
- RSpec.describe Codegen, :story_8_0a do
9
- let(:base_snapshot) do
8
+ RSpec.describe Codegen, :story_9_3 do
9
+ # Story 9.9 — the v1 (registry / `_makeRef`) render path was demolished;
10
+ # only the route-driven (version-2) snapshot is supported.
11
+ let(:v2_base) do
10
12
  {
11
- version: 1,
12
- generated_at: "2026-05-13T12:34:56Z",
13
+ version: 2,
14
+ generated_at: "2026-06-09T00:00:00Z",
13
15
  functions: []
14
16
  }
15
17
  end
16
18
 
17
- describe ".render — empty registry (Story 8.0a AC4)" do
18
- it "emits a valid module with import line and the empty-registry comment" do
19
- # Story 8.2 — the empty-registry branch also emits the
20
- # `export { revalidate } from "..."` re-export so
21
- # `import { revalidate } from "@/.ruact/server-functions"` works in
22
- # projects that have not yet declared any server actions.
23
- result = described_class.render(base_snapshot)
24
-
25
- expect(result).to eq(<<~TS)
26
- // AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.
27
- // Source: tmp/cache/ruact/server-functions.json (version 1)
28
- // Generated at: 2026-05-13T12:34:56Z
29
- import { _makeRef } from "ruact/server-functions-runtime";
30
-
31
- // (no server functions registered yet — Stories 8.1 / 9.1 populate)
32
- void _makeRef;
33
-
34
- export { revalidate } from "ruact/server-functions-runtime";
35
- TS
36
- end
37
-
38
- it "ends with exactly one trailing newline" do
39
- result = described_class.render(base_snapshot)
40
- expect(result).to end_with("\n")
41
- expect(result).not_to end_with("\n\n")
42
- end
43
-
44
- it "references _makeRef even when empty so noUnusedLocals stays green " \
45
- "(Re-run patch 2026-05-13)" do
46
- # Strictly checks that the import is "touched" — the canonical
47
- # `void _makeRef;` is the lightweight discard pattern.
48
- expect(described_class.render(base_snapshot)).to include("void _makeRef;")
49
- end
50
- end
51
-
52
- describe ".render — single action (Story 8.0a AC4 + Story 8.2 intersection)" do
53
- it "emits an action export typed as a TS intersection of direct-call + <form action> shapes" do
54
- # Story 8.2 (refined 2026-05-17 per review patch R1) — the
55
- # intersection makes `<form action={createPost}>` typecheck
56
- # directly against React 19's
57
- # `(formData: FormData) => void | Promise<void>` while preserving
58
- # `Promise<unknown>` for direct callers. See the 2026-05-17 entry
59
- # in `gem/docs/internal/decisions/server-functions-api.md`.
60
- snapshot = base_snapshot.merge(functions: [
61
- {
62
- "ruby_symbol" => "create_post",
63
- "js_identifier" => "createPost",
64
- "kind" => "action",
65
- "controller" => "PostsController"
66
- }
67
- ])
68
-
69
- expect(described_class.render(snapshot)).to eq(<<~TS)
70
- // AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.
71
- // Source: tmp/cache/ruact/server-functions.json (version 1)
72
- // Generated at: 2026-05-13T12:34:56Z
73
- import { _makeRef } from "ruact/server-functions-runtime";
74
-
75
- export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>) =
76
- _makeRef("create_post");
77
-
78
- export { revalidate } from "ruact/server-functions-runtime";
79
- TS
80
- end
81
-
82
- it "Story 8.2 — query signatures do NOT widen (regression guard)" do
83
- snapshot = base_snapshot.merge(functions: [
84
- {
85
- "ruby_symbol" => "categories",
86
- "js_identifier" => "categories",
87
- "kind" => "query",
88
- "controller" => "CategoriesController"
89
- }
90
- ])
91
-
92
- out = described_class.render(snapshot)
93
- # Action-style FormData widening must not bleed into the query export
94
- expect(out).not_to match(/export const categories:[^=]*FormData/)
95
- expect(out).to include("export const categories: () => Promise<unknown> =")
96
- end
97
- end
98
-
99
- describe ".render — single query (Story 8.0a AC4)" do
100
- it "emits a query export with the no-args signature" do
101
- snapshot = base_snapshot.merge(functions: [
102
- {
103
- "ruby_symbol" => "categories",
104
- "js_identifier" => "categories",
105
- "kind" => "query",
106
- "controller" => "CategoriesController"
107
- }
108
- ])
109
-
110
- expect(described_class.render(snapshot)).to include(
111
- "export const categories: () => Promise<unknown> =\n _makeRef(\"categories\");\n"
112
- )
113
- end
114
- end
115
-
116
- describe ".render — mixed action and query (Story 8.0a)" do
117
- it "emits both shapes in the order provided by the snapshot" do
118
- snapshot = base_snapshot.merge(functions: [
119
- { "ruby_symbol" => "create_post",
120
- "js_identifier" => "createPost",
121
- "kind" => "action",
122
- "controller" => "PostsController" },
123
- { "ruby_symbol" => "categories",
124
- "js_identifier" => "categories",
125
- "kind" => "query",
126
- "controller" => "CategoriesController" }
127
- ])
128
-
129
- out = described_class.render(snapshot)
130
- expect(out).to include("export const createPost:")
131
- expect(out).to include("export const categories:")
132
- expect(out.index("createPost")).to be < out.index("categories")
19
+ describe ".render — unsupported / malformed snapshots" do
20
+ it "rejects when snapshot is not a Hash" do
21
+ expect { described_class.render("oops") }
22
+ .to raise_error(Ruact::ConfigurationError, /snapshot must be a Hash/)
133
23
  end
134
- end
135
24
 
136
- describe ".render byte-stable across calls (Story 8.0a)" do
137
- it "produces byte-identical output for identical snapshots" do
138
- first = described_class.render(base_snapshot)
139
- second = described_class.render(base_snapshot.dup)
140
- expect(first).to eq(second)
141
- end
142
- end
143
-
144
- describe ".render — accepts symbol-keyed and string-keyed function entries" do
145
- it "tolerates either key style" do
146
- string_keys = base_snapshot.merge(functions: [
147
- { "ruby_symbol" => "demo_ping",
148
- "js_identifier" => "demoPing",
149
- "kind" => "action" }
150
- ])
151
- symbol_keys = base_snapshot.merge(functions: [
152
- { ruby_symbol: "demo_ping",
153
- js_identifier: "demoPing",
154
- kind: "action" }
155
- ])
156
- expect(described_class.render(string_keys)).to eq(described_class.render(symbol_keys))
25
+ it "wraps a missing root key as Ruact::ConfigurationError" do
26
+ evil = { generated_at: "2026-05-14T00:00:00Z", functions: [] } # no :version
27
+ expect { described_class.render(evil) }
28
+ .to raise_error(Ruact::ConfigurationError, /missing required key/)
157
29
  end
158
- end
159
-
160
- describe ".render — snapshot trust-boundary guards (Re-run patch 2026-05-13)" do
161
- it "rejects a snapshot entry whose js_identifier is not a valid JS identifier " \
162
- "(would otherwise inject TS at module top level)" do
163
- snapshot = base_snapshot.merge(functions: [
164
- { "ruby_symbol" => "create_post",
165
- "js_identifier" => ");\nevil();_makeRef(\"x",
166
- "kind" => "action" }
167
- ])
168
- expect { described_class.render(snapshot) }
169
- .to raise_error(Ruact::ConfigurationError) do |error|
170
- expect(error.message).to include("snapshot")
171
- expect(error.message).to include("valid JS identifier")
172
- end
173
- end
174
-
175
- it "rejects an entry with a non-String js_identifier" do
176
- snapshot = base_snapshot.merge(functions: [
177
- { "ruby_symbol" => "create_post",
178
- "js_identifier" => nil,
179
- "kind" => "action" }
180
- ])
181
- expect { described_class.render(snapshot) }
182
- .to raise_error(Ruact::ConfigurationError, /valid JS identifier/)
183
- end
184
-
185
- it "JSON-escapes the ruby_symbol argument to _makeRef so backslashes / quotes " \
186
- "in a (hand-edited or corrupted) snapshot cannot break out of the string literal" do
187
- # The js_identifier still has to be a valid identifier — only the
188
- # ruby_symbol can carry arbitrary string content. Escape it.
189
- snapshot = base_snapshot.merge(functions: [
190
- { "ruby_symbol" => "weird\"\\name",
191
- "js_identifier" => "weirdName",
192
- "kind" => "action" }
193
- ])
194
- out = described_class.render(snapshot)
195
- expect(out).to include('_makeRef("weird\"\\\\name");')
196
- end
197
- end
198
-
199
- describe ".render — snapshot trust-boundary guards (Re-run patch 2026-05-14)" do
200
- # The Ruby renderer reads the same on-disk JSON bridge as the JS-side
201
- # renderer (rake task path + Railtie path), so the same guards must
202
- # apply on both sides. Mirrors the JS-side `validateSnapshot`.
203
30
 
204
31
  it "rejects a snapshot whose version contains a line break" do
205
- evil = base_snapshot.merge(version: "1\n// injected")
32
+ evil = v2_base.merge(version: "2\n// injected")
206
33
  expect { described_class.render(evil) }
207
34
  .to raise_error(Ruact::ConfigurationError, /version.*line break/)
208
35
  end
209
36
 
210
37
  it "rejects a snapshot whose generated_at contains a line break" do
211
- evil = base_snapshot.merge(generated_at: "2026-05-14\n// injected")
38
+ evil = v2_base.merge(generated_at: "2026-05-14\n// injected")
212
39
  expect { described_class.render(evil) }
213
40
  .to raise_error(Ruact::ConfigurationError, /generated_at.*line break/)
214
41
  end
215
42
 
216
- it "rejects a snapshot whose functions field is not an Array" do
217
- evil = base_snapshot.merge(functions: "oops2
// injected")
218
- expect { described_class.render(evil) }
219
- .to raise_error(Ruact::ConfigurationError, /functions must be an Array/)
220
- end
221
-
222
- it "rejects a snapshot entry whose kind is not in the allowlist" do
223
- evil = base_snapshot.merge(functions: [
224
- { "ruby_symbol" => "foo",
225
- "js_identifier" => "foo",
226
- "kind" => "mutation" }
227
- ])
228
- expect { described_class.render(evil) }
229
- .to raise_error(Ruact::ConfigurationError, /invalid kind/)
230
- end
231
-
232
- it "rejects a snapshot whose entries duplicate a js_identifier" do
233
- evil = base_snapshot.merge(functions: [
234
- { "ruby_symbol" => "foo",
235
- "js_identifier" => "foo",
236
- "kind" => "action" },
237
- { "ruby_symbol" => "bar",
238
- "js_identifier" => "foo",
239
- "kind" => "query" }
240
- ])
241
- expect { described_class.render(evil) }
242
- .to raise_error(Ruact::ConfigurationError, /duplicate js_identifier "foo"/)
243
- end
244
-
245
- it "rejects a snapshot whose js_identifier is a JS reserved word" do
246
- evil = base_snapshot.merge(functions: [
247
- { "ruby_symbol" => "delete",
248
- "js_identifier" => "delete",
249
- "kind" => "action" }
250
- ])
251
- expect { described_class.render(evil) }
252
- .to raise_error(Ruact::ConfigurationError, /reserved JS word/)
253
- end
254
-
255
- it "rejects a snapshot whose version contains a U+2028 line separator " \
256
- "(Pass-2 patch 2026-05-14 — JS LineTerminator parity)" do
257
- evil = base_snapshot.merge(version: "1
// injected")
43
+ it "rejects a snapshot whose version contains a U+2028 line separator (JS LineTerminator parity)" do
44
+ evil = v2_base.merge(version: "2
// injected")
258
45
  expect { described_class.render(evil) }
259
46
  .to raise_error(Ruact::ConfigurationError, /line break.*U\+2028/)
260
47
  end
261
48
 
262
- it "rejects a snapshot whose generated_at contains a U+2029 paragraph separator " \
263
- "(Pass-2 patch 2026-05-14
// — JS LineTerminator parity)" do
264
- evil = base_snapshot.merge(generated_at: "2026-05-14
// injected")
49
+ it "rejects a snapshot whose generated_at contains a U+2029 paragraph separator (JS LineTerminator parity)" do
50
+ evil = v2_base.merge(generated_at: "2026-05-14
// injected")
265
51
  expect { described_class.render(evil) }
266
52
  .to raise_error(Ruact::ConfigurationError, /line break.*U\+2029/)
267
53
  end
268
54
 
269
- it "wraps Hash#fetch KeyError as Ruact::ConfigurationError when a root key is " \
270
- "missing (Pass-2 patch 2026-05-14)" do
271
- evil = { generated_at: "2026-05-14T00:00:00Z", functions: [] } # no :version
272
- expect { described_class.render(evil) }
273
- .to raise_error(Ruact::ConfigurationError, /missing required key/)
274
- end
275
-
276
- it "rejects an entry with empty ruby_symbol so we never emit _makeRef(\"\") " \
277
- "(Pass-2 patch 2026-05-14)" do
278
- evil = base_snapshot.merge(functions: [
279
- { "ruby_symbol" => "",
280
- "js_identifier" => "foo",
281
- "kind" => "action" }
282
- ])
283
- expect { described_class.render(evil) }
284
- .to raise_error(Ruact::ConfigurationError, /missing or empty ruby_symbol/)
285
- end
286
-
287
- it "rejects an entry with nil ruby_symbol (Pass-2 patch 2026-05-14)" do
288
- evil = base_snapshot.merge(functions: [
289
- { "ruby_symbol" => nil,
290
- "js_identifier" => "foo",
291
- "kind" => "action" }
292
- ])
55
+ it "rejects an unsupported (non-v2) snapshot version" do
56
+ evil = v2_base.merge(version: 1)
293
57
  expect { described_class.render(evil) }
294
- .to raise_error(Ruact::ConfigurationError, /missing or empty ruby_symbol/)
295
- end
296
-
297
- it "rejects when snapshot is not a Hash" do
298
- expect { described_class.render("oops") }
299
- .to raise_error(Ruact::ConfigurationError, /snapshot must be a Hash/)
58
+ .to raise_error(Ruact::ConfigurationError, /unsupported snapshot version/)
300
59
  end
301
60
  end
302
61
 
303
- describe ".generate_ts! (Story 8.0a — write-if-changed wrapper)" do
62
+ describe ".generate_ts! (write-if-changed wrapper)" do
304
63
  around do |example|
305
64
  Dir.mktmpdir do |dir|
306
65
  @tmpdir = dir
@@ -311,14 +70,14 @@ module Ruact
311
70
  let(:path) { File.join(@tmpdir, "server-functions.ts") }
312
71
 
313
72
  it "writes the file on first call and returns true" do
314
- expect(described_class.generate_ts!(snapshot: base_snapshot, output_path: path))
73
+ expect(described_class.generate_ts!(snapshot: v2_base, output_path: path))
315
74
  .to be(true)
316
75
  expect(File.read(path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
317
76
  end
318
77
 
319
- it "does NOT rewrite when the content is byte-identical (Story 8.0a)" do
320
- described_class.generate_ts!(snapshot: base_snapshot, output_path: path)
321
- expect(described_class.generate_ts!(snapshot: base_snapshot, output_path: path))
78
+ it "does NOT rewrite when the content is byte-identical" do
79
+ described_class.generate_ts!(snapshot: v2_base, output_path: path)
80
+ expect(described_class.generate_ts!(snapshot: v2_base, output_path: path))
322
81
  .to be(false)
323
82
  end
324
83
  end
@@ -503,6 +262,136 @@ module Ruact
503
262
  expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
504
263
  end
505
264
  end
265
+
266
+ describe ".render — typed query params (Story 13.4)", :story_13_4 do
267
+ def typed_query(js_id, path, params:, params_rest: false)
268
+ {
269
+ "js_identifier" => js_id, "kind" => "query", "http_method" => "GET",
270
+ "path" => path, "segments" => [],
271
+ "accepts_params" => !params.empty? || params_rest,
272
+ "params" => params, "params_rest" => params_rest,
273
+ "controller" => "CatalogQuery", "action" => js_id
274
+ }
275
+ end
276
+
277
+ let(:union) { "string | number | boolean | null" }
278
+
279
+ it "emits a typed params object (required + optional, declaration order)" do
280
+ out = described_class.render(version: 2, generated_at: "t", functions: [
281
+ typed_query("searchUsers", "/q/searchUsers", params: [
282
+ { "name" => "term", "required" => true },
283
+ { "name" => "limit", "required" => false }
284
+ ])
285
+ ])
286
+ expect(out).to include(
287
+ "export const searchUsers: (params: { term: #{union}; limit?: #{union} }) => Promise<unknown> ="
288
+ )
289
+ end
290
+
291
+ it "emits the bare () signature for a query with empty params + no rest" do
292
+ out = described_class.render(version: 2, generated_at: "t", functions: [
293
+ typed_query("categories", "/q/categories", params: [])
294
+ ])
295
+ expect(out).to include("export const categories: () => Promise<unknown> =")
296
+ end
297
+
298
+ it "intersects named keys with Record<string, unknown> for a **keyrest query" do
299
+ out = described_class.render(version: 2, generated_at: "t", functions: [
300
+ typed_query("withRest", "/q/withRest",
301
+ params: [{ "name" => "scope", "required" => true }],
302
+ params_rest: true)
303
+ ])
304
+ expect(out).to include(
305
+ "export const withRest: (params: { scope: #{union} } & Record<string, unknown>) => Promise<unknown> ="
306
+ )
307
+ end
308
+
309
+ it "keeps the open Record<string, unknown> for a **keyrest-only query" do
310
+ out = described_class.render(version: 2, generated_at: "t", functions: [
311
+ typed_query("restOnly", "/q/restOnly", params: [], params_rest: true)
312
+ ])
313
+ expect(out).to include(
314
+ "export const restOnly: (params: Record<string, unknown>) => Promise<unknown> ="
315
+ )
316
+ end
317
+
318
+ it "quotes a param key that is not a valid bare JS identifier" do
319
+ out = described_class.render(version: 2, generated_at: "t", functions: [
320
+ typed_query("oddKey", "/q/oddKey",
321
+ params: [{ "name" => "weird-key", "required" => false }])
322
+ ])
323
+ expect(out).to include("(params: { \"weird-key\"?: #{union} }) => Promise<unknown>")
324
+ end
325
+
326
+ it "falls back to the accepts_params boolean when no params metadata is present" do
327
+ out = described_class.render(version: 2, generated_at: "t", functions: [
328
+ { "js_identifier" => "legacyQuery", "kind" => "query",
329
+ "http_method" => "GET", "path" => "/q/legacyQuery",
330
+ "segments" => [], "accepts_params" => true }
331
+ ])
332
+ expect(out).to include(
333
+ "export const legacyQuery: (params: Record<string, unknown>) => Promise<unknown> ="
334
+ )
335
+ end
336
+
337
+ it "rejects params that are not an Array (trust boundary)" do
338
+ evil = typed_query("x", "/q/x", params: []).merge("params" => "oops")
339
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
340
+ .to raise_error(Ruact::ConfigurationError, /invalid.*params/)
341
+ end
342
+
343
+ it "rejects a params element that is not an object" do
344
+ evil = typed_query("x", "/q/x", params: ["nope"])
345
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
346
+ .to raise_error(Ruact::ConfigurationError, /params element that is not an object/)
347
+ end
348
+
349
+ it "rejects a params name containing a line break (TS injection guard)" do
350
+ evil = typed_query("x", "/q/x", params: [{ "name" => "a\n// injected", "required" => true }])
351
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
352
+ .to raise_error(Ruact::ConfigurationError, /non-empty single-line String/)
353
+ end
354
+
355
+ it "rejects a params name that is empty" do
356
+ evil = typed_query("x", "/q/x", params: [{ "name" => "", "required" => true }])
357
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
358
+ .to raise_error(Ruact::ConfigurationError, /non-empty single-line String/)
359
+ end
360
+
361
+ it "rejects a non-Boolean required" do
362
+ evil = typed_query("x", "/q/x", params: [{ "name" => "q", "required" => "yes" }])
363
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
364
+ .to raise_error(Ruact::ConfigurationError, /non-Boolean .*required/)
365
+ end
366
+
367
+ it "rejects a non-Boolean params_rest (would silently over-widen the type)" do
368
+ evil = typed_query("x", "/q/x", params: []).merge("params_rest" => "true")
369
+ expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
370
+ .to raise_error(Ruact::ConfigurationError, /non-Boolean params_rest/)
371
+ end
372
+
373
+ it "accepts a legitimate required: false (not mis-read as absent)" do
374
+ expect do
375
+ described_class.render(version: 2, generated_at: "t", functions: [
376
+ typed_query("q1", "/q/q1", params: [{ "name" => "limit", "required" => false }])
377
+ ])
378
+ end.not_to raise_error
379
+ end
380
+
381
+ it "is byte-stable: regenerating a typed-query snapshot writes nothing the second pass" do
382
+ Dir.mktmpdir do |dir|
383
+ path = File.join(dir, "server-functions.ts")
384
+ snapshot = { version: 2, generated_at: "t", functions: [
385
+ typed_query("searchUsers", "/q/searchUsers", params: [
386
+ { "name" => "term", "required" => true },
387
+ { "name" => "limit", "required" => false }
388
+ ])
389
+ ] }
390
+ expect(described_class.generate_ts!(snapshot: snapshot, output_path: path)).to be(true)
391
+ expect(described_class.generate_ts!(snapshot: snapshot, output_path: path)).to be(false)
392
+ end
393
+ end
394
+ end
506
395
  end
507
396
  end
508
397
  end
@@ -36,7 +36,7 @@ module Ruact
36
36
  expect { described_class.to_js_identifier(:CreatePost) }
37
37
  .to raise_error(Ruact::ConfigurationError) do |error|
38
38
  expect(error.message).to include(":CreatePost")
39
- expect(error.message).to include("ruact_action / ruact_query")
39
+ expect(error.message).to include("ruact server-function name")
40
40
  end
41
41
  end
42
42
 
@@ -150,10 +150,10 @@ module Ruact
150
150
 
151
151
  describe "Ruact-reserved names (Story 8.2 review patch R2 — 2026-05-17)", :story_8_2 do
152
152
  # The codegen unconditionally re-exports certain runtime helpers
153
- # from `@/.ruact/server-functions` (e.g. `revalidate`). A
154
- # `ruact_action :revalidate` would emit `export const revalidate`
153
+ # from `@/.ruact/server-functions` (e.g. `revalidate`). A server
154
+ # function named `revalidate` would emit `export const revalidate`
155
155
  # next to the helper re-export and crash at module load with a
156
- # duplicate-export error. Reject at controller load instead.
156
+ # duplicate-export error. Reject at route-draw instead.
157
157
  it "rejects :revalidate because it collides with the unconditional helper re-export" do
158
158
  expect { described_class.to_js_identifier(:revalidate) }
159
159
  .to raise_error(Ruact::ConfigurationError) do |error|
@@ -170,16 +170,16 @@ module Ruact
170
170
  expect(described_class.to_js_identifier(:_revalidate)).to eq("_revalidate")
171
171
  end
172
172
 
173
- it "R12 — rejects :_make_ref because it collides with the codegen's runtime import" do
174
- expect { described_class.to_js_identifier(:_make_ref) }
173
+ it "rejects :_make_server_function because it collides with the codegen's runtime import" do
174
+ expect { described_class.to_js_identifier(:_make_server_function) }
175
175
  .to raise_error(Ruact::ConfigurationError) do |error|
176
- expect(error.message).to include(":_make_ref")
176
+ expect(error.message).to include(":_make_server_function")
177
177
  expect(error.message).to include("duplicate export")
178
178
  end
179
179
  end
180
180
 
181
- it "R12 — accepts :_make_ref_action (suffix escape hatch keeps working)" do
182
- expect(described_class.to_js_identifier(:_make_ref_action)).to eq("_makeRefAction")
181
+ it "accepts :_make_ref now that the demolished v1 runtime export is no longer reserved (Story 9.9)" do
182
+ expect(described_class.to_js_identifier(:_make_ref)).to eq("_makeRef")
183
183
  end
184
184
 
185
185
  # Story 9.5 — `_makeQuery` (the v2 query import) and `useQuery` (the
@@ -19,6 +19,16 @@ module Ruact
19
19
  def search_users(term:); end
20
20
  end
21
21
 
22
+ # Story 13.4 — kwarg shapes exercising the per-param metadata derivation:
23
+ # a `**keyrest` (anonymous + named), and a positional that must be ignored.
24
+ class ParamShapesQ
25
+ def with_rest(scope:, **opts); end
26
+
27
+ def rest_only(**opts); end
28
+
29
+ def positional_ignored(id, query:); end
30
+ end
31
+
22
32
  # Story 9.5 — query introspection: the drawn route table (filtered to the
23
33
  # generated query dispatch controllers) → v2 query entries. Pure: route set
24
34
  # and the query-class resolver are injected, so the derivation is testable
@@ -82,6 +92,47 @@ module Ruact
82
92
  expect(by_id["searchUsers"]["accepts_params"]).to be(true)
83
93
  end
84
94
 
95
+ it "derives per-kwarg params metadata (name + required/optional, declaration order)", :story_13_4 do
96
+ rs = route_set([query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")])
97
+ entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
98
+ expect(entry["params"]).to eq([
99
+ { "name" => "term", "required" => true },
100
+ { "name" => "limit", "required" => false }
101
+ ])
102
+ expect(entry["params_rest"]).to be(false)
103
+ end
104
+
105
+ it "emits empty params + accepts_params false for a no-kwargs query", :story_13_4 do
106
+ rs = route_set([query_route("#{prefix}catalog_q", "categories", "/q/categories")])
107
+ entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
108
+ expect(entry["params"]).to eq([])
109
+ expect(entry["params_rest"]).to be(false)
110
+ expect(entry["accepts_params"]).to be(false)
111
+ end
112
+
113
+ it "flags params_rest for a `**keyrest` and still carries the named keys", :story_13_4 do
114
+ rs = route_set([query_route("#{prefix}params_q", "with_rest", "/q/withRest")])
115
+ entry = described_class.collect(rs, query_class_for: resolver("#{prefix}params_q" => ParamShapesQ)).first
116
+ expect(entry["params"]).to eq([{ "name" => "scope", "required" => true }])
117
+ expect(entry["params_rest"]).to be(true)
118
+ expect(entry["accepts_params"]).to be(true)
119
+ end
120
+
121
+ it "treats a `**keyrest`-only query as accepts_params (rest, no named keys)", :story_13_4 do
122
+ rs = route_set([query_route("#{prefix}params_q", "rest_only", "/q/restOnly")])
123
+ entry = described_class.collect(rs, query_class_for: resolver("#{prefix}params_q" => ParamShapesQ)).first
124
+ expect(entry["params"]).to eq([])
125
+ expect(entry["params_rest"]).to be(true)
126
+ expect(entry["accepts_params"]).to be(true)
127
+ end
128
+
129
+ it "ignores positional params (only kwargs are FR88 query params)", :story_13_4 do
130
+ rs = route_set([query_route("#{prefix}params_q", "positional_ignored", "/q/positionalIgnored")])
131
+ entry = described_class.collect(rs, query_class_for: resolver("#{prefix}params_q" => ParamShapesQ)).first
132
+ expect(entry["params"]).to eq([{ "name" => "query", "required" => true }])
133
+ expect(entry["params_rest"]).to be(false)
134
+ end
135
+
85
136
  it "ignores non-query routes (controller not under the dispatch namespace)" do
86
137
  rs = route_set([
87
138
  query_route("posts", "create", "/posts"),