ruact 0.0.5 → 0.0.7

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 (135) 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 +74 -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 +79 -425
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +71 -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/manifest_resolver.rb +149 -0
  41. data/lib/ruact/railtie.rb +70 -203
  42. data/lib/ruact/render_pipeline.rb +8 -1
  43. data/lib/ruact/server.rb +28 -6
  44. data/lib/ruact/server_functions/codegen.rb +49 -188
  45. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  46. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  47. data/lib/ruact/server_functions/error_payload.rb +1 -1
  48. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  49. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  50. data/lib/ruact/server_functions/query_source.rb +35 -8
  51. data/lib/ruact/server_functions/route_source.rb +3 -4
  52. data/lib/ruact/server_functions/snapshot.rb +12 -139
  53. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  54. data/lib/ruact/server_functions.rb +21 -25
  55. data/lib/ruact/signed_references.rb +162 -0
  56. data/lib/ruact/string_distance.rb +72 -0
  57. data/lib/ruact/validation_errors_collector.rb +139 -0
  58. data/lib/ruact/version.rb +1 -1
  59. data/lib/ruact/view_helper.rb +109 -0
  60. data/lib/ruact.rb +20 -19
  61. data/lib/tasks/ruact.rake +10 -53
  62. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  63. data/spec/ruact/client_manifest_spec.rb +36 -0
  64. data/spec/ruact/component_contract_spec.rb +119 -0
  65. data/spec/ruact/configuration_spec.rb +51 -34
  66. data/spec/ruact/controller_request_spec.rb +264 -0
  67. data/spec/ruact/controller_spec.rb +70 -328
  68. data/spec/ruact/doctor_spec.rb +201 -0
  69. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  70. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  71. data/spec/ruact/errors_spec.rb +0 -45
  72. data/spec/ruact/html_converter_spec.rb +50 -0
  73. data/spec/ruact/install_generator_spec.rb +591 -4
  74. data/spec/ruact/manifest_resolver_spec.rb +159 -0
  75. data/spec/ruact/query_request_spec.rb +109 -1
  76. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  77. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  78. data/spec/ruact/server_function_name_spec.rb +1 -1
  79. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  80. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  81. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  82. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  83. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  84. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  85. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  86. data/spec/ruact/server_spec.rb +8 -9
  87. data/spec/ruact/signed_references_spec.rb +164 -0
  88. data/spec/ruact/string_distance_spec.rb +38 -0
  89. data/spec/ruact/validation_errors_spec.rb +116 -0
  90. data/spec/ruact/view_helper_spec.rb +79 -0
  91. data/spec/spec_helper.rb +15 -5
  92. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  93. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  94. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  95. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  96. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  97. data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
  98. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  99. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  100. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  101. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  102. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  103. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  104. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  105. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  106. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  107. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  108. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  109. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  110. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  119. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  120. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  121. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  122. metadata +58 -15
  123. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  124. data/lib/ruact/server_action.rb +0 -131
  125. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  126. data/lib/ruact/server_functions/registry.rb +0 -148
  127. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  128. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  129. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  130. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  131. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  132. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  133. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  134. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  135. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -32,147 +32,22 @@ afterEach(() => {
32
32
  delete process.env.RUACT_SILENCE_LOG;
33
33
  });
34
34
 
35
+ // Story 9.9 — the v1 (registry / `_makeRef`) render path was demolished; the
36
+ // base snapshot is now the route-driven (version-2) shape.
35
37
  const baseSnapshot = (over = {}) => ({
36
- version: 1,
38
+ version: 2,
37
39
  generated_at: "2026-05-13T12:34:56Z",
38
40
  functions: [],
39
41
  ...over,
40
42
  });
41
43
 
42
- describe("Story 8.0a render()", () => {
43
- it("emits empty module when registry JSON has no functions", () => {
44
- // Story 8.2 — the empty-registry branch now also emits the
45
- // `export { revalidate } from "ruact/server-functions-runtime";`
46
- // line so `import { revalidate } from "@/.ruact/server-functions"`
47
- // works in projects that have not declared any server actions yet.
48
- expect(render(baseSnapshot())).toEqual(
49
- [
50
- "// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.",
51
- "// Source: tmp/cache/ruact/server-functions.json (version 1)",
52
- "// Generated at: 2026-05-13T12:34:56Z",
53
- `import { _makeRef } from "${RUNTIME_IMPORT_SPECIFIER}";`,
54
- "",
55
- "// (no server functions registered yet — Stories 8.1 / 9.1 populate)",
56
- "void _makeRef;",
57
- "",
58
- `export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";`,
59
- "",
60
- ].join("\n"),
61
- );
62
- });
63
-
64
- it("references _makeRef via `void` even when empty so noUnusedLocals stays " +
65
- "green (Re-run patch 2026-05-13)", () => {
66
- expect(render(baseSnapshot())).toContain("void _makeRef;");
67
- });
68
-
69
- it("emits the revalidate re-export (Story 8.2) in both empty + populated branches", () => {
70
- expect(render(baseSnapshot())).toContain(
71
- `export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";`,
72
- );
73
- expect(
74
- render(
75
- baseSnapshot({
76
- functions: [{ ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" }],
77
- }),
78
- ),
79
- ).toContain(`export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";`);
80
- });
81
-
82
- it("rejects a snapshot entry whose js_identifier is not a valid JS identifier " +
83
- "(snapshot trust-boundary guard — Re-run patch 2026-05-13)", () => {
84
- const evil = baseSnapshot({
85
- functions: [
86
- {
87
- ruby_symbol: "create_post",
88
- js_identifier: ');\nevil();_makeRef("x',
89
- kind: "action",
90
- },
91
- ],
92
- });
93
- expect(() => render(evil)).toThrow(/valid JS identifier/);
94
- });
95
-
96
- it("JSON-escapes ruby_symbol in the _makeRef argument so quotes/backslashes " +
97
- "cannot break out (Re-run patch 2026-05-13)", () => {
98
- const weird = baseSnapshot({
99
- functions: [
100
- {
101
- ruby_symbol: 'weird"\\name',
102
- js_identifier: "weirdName",
103
- kind: "action",
104
- },
105
- ],
106
- });
107
- // The emitted call site should contain a JSON-escaped string literal.
108
- expect(render(weird)).toContain('_makeRef("weird\\"\\\\name");');
109
- });
110
-
111
- it("emits one export per registered function", () => {
112
- const fns = [
113
- { ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" },
114
- { ruby_symbol: "delete_post", js_identifier: "deletePost", kind: "action" },
115
- ];
116
- const out = render(baseSnapshot({ functions: fns }));
117
- expect((out.match(/export const /g) || []).length).toBe(2);
118
- expect(out).toContain("export const createPost:");
119
- expect(out).toContain("export const deletePost:");
120
- });
121
-
122
- it("emits action and query exports with identical accessor mechanics " +
123
- "(both call _makeRef)", () => {
124
- const out = render(
125
- baseSnapshot({
126
- functions: [
127
- { ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" },
128
- { ruby_symbol: "categories", js_identifier: "categories", kind: "query" },
129
- ],
130
- }),
131
- );
132
- expect(out).toContain(`_makeRef("create_post")`);
133
- expect(out).toContain(`_makeRef("categories")`);
134
- });
135
-
136
- it("Story 8.2 — action signatures emit the intersection (direct callable + " +
137
- "`<form action>`-compatible overload); query signatures stay narrow", () => {
138
- // R1 (2026-05-17 review patch): the previous widening to
139
- // `(args?: FormData | Record<string, unknown>) => Promise<unknown>`
140
- // alone did NOT make `<form action={createPost}>` typecheck against
141
- // React 19's `(formData: FormData) => void | Promise<void>` — Promise
142
- // generics are invariant. The intersection adds a second
143
- // `(formData: FormData) => Promise<void>` signature so the same export
144
- // satisfies both call sites.
145
- const out = render(
146
- baseSnapshot({
147
- functions: [
148
- { ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" },
149
- { ruby_symbol: "categories", js_identifier: "categories", kind: "query" },
150
- ],
151
- }),
152
- );
153
- expect(out).toContain(
154
- "export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>) =",
155
- );
156
- expect(out).toContain("export const categories: () => Promise<unknown> =");
157
- });
158
-
159
- it("Story 8.2 — query signatures do NOT widen (regression guard against accidental " +
160
- "carry-over of the action widening)", () => {
161
- const out = render(
162
- baseSnapshot({
163
- functions: [{ ruby_symbol: "categories", js_identifier: "categories", kind: "query" }],
164
- }),
165
- );
166
- // The literal action-style FormData union must NOT appear next to a query export.
167
- expect(out).not.toMatch(/export const categories:[^=]*FormData/);
168
- expect(out).toContain("export const categories: () => Promise<unknown> =");
169
- });
170
-
171
- it("ends with exactly one trailing newline", () => {
172
- const out = render(baseSnapshot());
173
- expect(out.endsWith("\n")).toBe(true);
174
- expect(out.endsWith("\n\n")).toBe(false);
175
- });
44
+ const v2ActionEntry = (over = {}) => ({
45
+ js_identifier: "demoPing",
46
+ kind: "action",
47
+ http_method: "POST",
48
+ path: "/demo_ping",
49
+ segments: [],
50
+ ...over,
176
51
  });
177
52
 
178
53
  describe("Story 8.0a — writeIfChanged()", () => {
@@ -224,7 +99,7 @@ describe("Story 8.0a — generateOnce()", () => {
224
99
  snapshotJson,
225
100
  JSON.stringify(
226
101
  baseSnapshot({
227
- functions: [{ ruby_symbol: "demo_ping", js_identifier: "demoPing", kind: "action" }],
102
+ functions: [v2ActionEntry()],
228
103
  }),
229
104
  ),
230
105
  );
@@ -429,9 +304,9 @@ describe("Story 8.0a — installServerFunctionsHooks() composition", () => {
429
304
  });
430
305
  });
431
306
 
432
- describe("Story 8.0a — snapshot validation (Chunk 2 M1 trust-boundary guards)", () => {
307
+ describe("snapshot validation version-agnostic metadata guards (trust boundary)", () => {
433
308
  it("rejects snapshot whose version contains a newline (would break out of comment)", () => {
434
- const evil = baseSnapshot({ version: "1\n// injected" });
309
+ const evil = baseSnapshot({ version: "2\n// injected" });
435
310
  expect(() => render(evil)).toThrow(/version.*line break|line break.*version/i);
436
311
  });
437
312
 
@@ -440,86 +315,28 @@ describe("Story 8.0a — snapshot validation (Chunk 2 M1 — trust-boundary guar
440
315
  expect(() => render(evil)).toThrow(/generated_at.*line break|line break.*generated_at/i);
441
316
  });
442
317
 
443
- it("rejects snapshot whose functions field is not an array", () => {
444
- const evil = baseSnapshot({ functions: "oops2
// injected" });
445
- expect(() => render(evil)).toThrow(/functions.*array/);
446
- });
447
-
448
- it("rejects snapshot entry with unknown kind", () => {
449
- const evil = baseSnapshot({
450
- functions: [{ ruby_symbol: "foo", js_identifier: "foo", kind: "mutation" }],
451
- });
452
- expect(() => render(evil)).toThrow(/invalid kind/);
453
- });
454
-
455
- it("rejects snapshot with duplicate js_identifier entries", () => {
456
- const evil = baseSnapshot({
457
- functions: [
458
- { ruby_symbol: "foo", js_identifier: "foo", kind: "action" },
459
- { ruby_symbol: "bar", js_identifier: "foo", kind: "query" },
460
- ],
461
- });
462
- expect(() => render(evil)).toThrow(/duplicate js_identifier "foo"/);
463
- });
464
-
465
- it("rejects snapshot entry whose js_identifier is a JS reserved word", () => {
466
- const evil = baseSnapshot({
467
- functions: [{ ruby_symbol: "delete", js_identifier: "delete", kind: "action" }],
468
- });
469
- expect(() => render(evil)).toThrow(/reserved JS word/);
470
- });
471
-
472
- it("R12 — rejects snapshot entry whose js_identifier is reserved by ruact " +
473
- "(`revalidate` — would clash with the helper re-export)", () => {
474
- const evil = baseSnapshot({
475
- functions: [{ ruby_symbol: "revalidate", js_identifier: "revalidate", kind: "action" }],
476
- });
477
- expect(() => render(evil)).toThrow(/reserved by the ruact runtime\/codegen surface/);
478
- });
479
-
480
- it("R12 — rejects snapshot entry whose js_identifier is `_makeRef` " +
481
- "(would clash with the codegen's runtime import)", () => {
482
- const evil = baseSnapshot({
483
- functions: [{ ruby_symbol: "make_ref", js_identifier: "_makeRef", kind: "action" }],
484
- });
485
- expect(() => render(evil)).toThrow(/reserved by the ruact runtime\/codegen surface/);
486
- });
487
-
488
- it("rejects snapshot whose version contains U+2028 LINE SEPARATOR " +
489
- "(Pass-2 patch 2026-05-14 — JS LineTerminator parity)", () => {
490
- const evil = baseSnapshot({ version: "1
// injected" });
318
+ it("rejects snapshot whose version contains U+2028 LINE SEPARATOR (JS LineTerminator parity)", () => {
319
+ const evil = baseSnapshot({ version: "2
// injected" });
491
320
  expect(() => render(evil)).toThrow(/U\+2028/);
492
321
  });
493
322
 
494
- it("rejects snapshot whose generated_at contains U+2029 PARAGRAPH SEPARATOR " +
495
- "(Pass-2 patch 2026-05-14)", () => {
323
+ it("rejects snapshot whose generated_at contains U+2029 PARAGRAPH SEPARATOR", () => {
496
324
  const evil = baseSnapshot({ generated_at: "2026-05-14
// injected" });
497
325
  expect(() => render(evil)).toThrow(/U\+2029/);
498
326
  });
499
327
 
500
- it("rejects snapshot missing a root key (Pass-2 patch 2026-05-14)", () => {
328
+ it("rejects snapshot missing a root key", () => {
501
329
  const evil = { generated_at: "x", functions: [] }; // no version
502
330
  expect(() => render(evil)).toThrow(/missing required key "version"/);
503
331
  });
504
332
 
505
- it("rejects entry with empty ruby_symbol so we never emit _makeRef(\"\") " +
506
- "(Pass-2 patch 2026-05-14)", () => {
507
- const evil = baseSnapshot({
508
- functions: [{ ruby_symbol: "", js_identifier: "foo", kind: "action" }],
509
- });
510
- expect(() => render(evil)).toThrow(/missing or empty ruby_symbol/);
511
- });
512
-
513
- it("rejects entry with missing ruby_symbol field (Pass-2 patch 2026-05-14)", () => {
514
- const evil = baseSnapshot({
515
- functions: [{ js_identifier: "foo", kind: "action" }],
516
- });
517
- expect(() => render(evil)).toThrow(/missing or empty ruby_symbol/);
333
+ it("rejects when snapshot itself is not an object (e.g., an array)", () => {
334
+ expect(() => render([])).toThrow(/snapshot is not an object/);
518
335
  });
519
336
 
520
- it("rejects when snapshot itself is not an object (e.g., an array) " +
521
- "(Pass-2 patch 2026-05-14)", () => {
522
- expect(() => render([])).toThrow(/snapshot is not an object/);
337
+ it("rejects an unsupported (non-v2) snapshot version (Story 9.9)", () => {
338
+ const evil = baseSnapshot({ version: 1 });
339
+ expect(() => render(evil)).toThrow(/unsupported snapshot version/);
523
340
  });
524
341
  });
525
342
 
@@ -588,7 +405,7 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
588
405
  snapshotJson,
589
406
  JSON.stringify(
590
407
  baseSnapshot({
591
- functions: [{ ruby_symbol: "demo_ping", js_identifier: "demoPing", kind: "action" }],
408
+ functions: [v2ActionEntry()],
592
409
  }),
593
410
  ),
594
411
  );
@@ -644,7 +461,7 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
644
461
  snapshotJson,
645
462
  JSON.stringify(
646
463
  baseSnapshot({
647
- functions: [{ ruby_symbol: "categories", js_identifier: "categories", kind: "query" }],
464
+ functions: [v2ActionEntry({ js_identifier: "categories", http_method: "POST", path: "/categories" })],
648
465
  }),
649
466
  ),
650
467
  );
@@ -672,7 +489,7 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
672
489
  snapshotJson,
673
490
  JSON.stringify(
674
491
  baseSnapshot({
675
- functions: [{ ruby_symbol: "demo_ping", js_identifier: "demoPing", kind: "action" }],
492
+ functions: [v2ActionEntry()],
676
493
  }),
677
494
  ),
678
495
  );
@@ -687,60 +504,6 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
687
504
  });
688
505
  });
689
506
 
690
- describe("Story 8.0a — Ruby parity (Task 8.5)", () => {
691
- // The Ruby codegen reads the same fixture and must produce byte-identical
692
- // output. If this test fails, the Ruby OR JS side drifted — diff the outputs
693
- // and bring them back into sync rather than normalizing in the assertion.
694
-
695
- const fixture = {
696
- version: 1,
697
- generated_at: "2026-05-13T12:34:56Z",
698
- functions: [
699
- {
700
- ruby_symbol: "alpha",
701
- js_identifier: "alpha",
702
- kind: "query",
703
- controller: "AlphaController",
704
- },
705
- {
706
- ruby_symbol: "create_post",
707
- js_identifier: "createPost",
708
- kind: "action",
709
- controller: "PostsController",
710
- },
711
- {
712
- ruby_symbol: "_internal_dump",
713
- js_identifier: "_internalDump",
714
- kind: "action",
715
- controller: "InternalsController",
716
- },
717
- ],
718
- };
719
-
720
- it("Ruby's Codegen.render and JS's render produce byte-identical output", () => {
721
- const jsOutput = render(fixture);
722
-
723
- // Shell out to Ruby. The gem lib path is two levels up from this file.
724
- const gemLibPath = path.resolve(
725
- path.dirname(new URL(import.meta.url).pathname),
726
- "..",
727
- "..",
728
- "..",
729
- "lib",
730
- );
731
- const fixtureJson = JSON.stringify(fixture);
732
- const script =
733
- 'require "ruact/server_functions/codegen"; ' +
734
- 'require "json"; ' +
735
- `print Ruact::ServerFunctions::Codegen.render(JSON.parse('${fixtureJson}'))`;
736
- const rubyOutput = execFileSync("ruby", ["-I", gemLibPath, "-e", script], {
737
- encoding: "utf8",
738
- });
739
-
740
- expect(jsOutput).toBe(rubyOutput);
741
- });
742
- });
743
-
744
507
  describe("Story 9.3 — route-driven (v2) render + parity", () => {
745
508
  const v2Fixture = {
746
509
  version: 2,
@@ -959,3 +722,177 @@ describe("Story 9.5 — v2 query entries render + parity", () => {
959
722
  expect(jsOutput).toBe(rubyOutput);
960
723
  });
961
724
  });
725
+
726
+ describe("Story 13.4 — typed query params render + parity", () => {
727
+ const UNION = "string | number | boolean | null";
728
+
729
+ const typedQueryFixture = {
730
+ version: 2,
731
+ generated_at: "2026-06-26T00:00:00Z",
732
+ functions: [
733
+ // no-kwargs query → bare () signature (byte-identical to 9.5)
734
+ {
735
+ js_identifier: "categories",
736
+ kind: "query",
737
+ http_method: "GET",
738
+ path: "/q/categories",
739
+ segments: [],
740
+ accepts_params: false,
741
+ params: [],
742
+ params_rest: false,
743
+ controller: "CatalogQuery",
744
+ action: "categories",
745
+ },
746
+ // required + optional kwargs → typed object, declaration order
747
+ {
748
+ js_identifier: "searchUsers",
749
+ kind: "query",
750
+ http_method: "GET",
751
+ path: "/q/searchUsers",
752
+ segments: [],
753
+ accepts_params: true,
754
+ params: [
755
+ { name: "term", required: true },
756
+ { name: "limit", required: false },
757
+ ],
758
+ params_rest: false,
759
+ controller: "CatalogQuery",
760
+ action: "search_users",
761
+ },
762
+ // named key + **keyrest → intersection with the open record
763
+ {
764
+ js_identifier: "withRest",
765
+ kind: "query",
766
+ http_method: "GET",
767
+ path: "/q/withRest",
768
+ segments: [],
769
+ accepts_params: true,
770
+ params: [{ name: "scope", required: true }],
771
+ params_rest: true,
772
+ controller: "CatalogQuery",
773
+ action: "with_rest",
774
+ },
775
+ // **keyrest-only → open record
776
+ {
777
+ js_identifier: "restOnly",
778
+ kind: "query",
779
+ http_method: "GET",
780
+ path: "/q/restOnly",
781
+ segments: [],
782
+ accepts_params: true,
783
+ params: [],
784
+ params_rest: true,
785
+ controller: "CatalogQuery",
786
+ action: "rest_only",
787
+ },
788
+ ],
789
+ };
790
+
791
+ it("emits typed params objects (required/optional, keyrest, declaration order)", () => {
792
+ const out = render(typedQueryFixture);
793
+ expect(out).toContain("export const categories: () => Promise<unknown> =");
794
+ expect(out).toContain(
795
+ `export const searchUsers: (params: { term: ${UNION}; limit?: ${UNION} }) => Promise<unknown> =`,
796
+ );
797
+ expect(out).toContain(
798
+ `export const withRest: (params: { scope: ${UNION} } & Record<string, unknown>) => Promise<unknown> =`,
799
+ );
800
+ expect(out).toContain(
801
+ "export const restOnly: (params: Record<string, unknown>) => Promise<unknown> =",
802
+ );
803
+ });
804
+
805
+ it("rejects malformed params metadata (trust boundary)", () => {
806
+ expect(() =>
807
+ render({
808
+ version: 2,
809
+ generated_at: "x",
810
+ functions: [
811
+ {
812
+ js_identifier: "x",
813
+ kind: "query",
814
+ http_method: "GET",
815
+ path: "/q/x",
816
+ segments: [],
817
+ params: [{ name: "q\n// injected", required: true }],
818
+ },
819
+ ],
820
+ }),
821
+ ).toThrow(/non-empty single-line string/);
822
+ });
823
+
824
+ it("rejects a non-boolean params_rest (would silently over-widen the type)", () => {
825
+ expect(() =>
826
+ render({
827
+ version: 2,
828
+ generated_at: "x",
829
+ functions: [
830
+ {
831
+ js_identifier: "x",
832
+ kind: "query",
833
+ http_method: "GET",
834
+ path: "/q/x",
835
+ segments: [],
836
+ params: [],
837
+ params_rest: "true",
838
+ },
839
+ ],
840
+ }),
841
+ ).toThrow(/non-boolean params_rest/);
842
+ });
843
+
844
+ it("Ruby's render and JS's render produce byte-identical typed-query output", () => {
845
+ const jsOutput = render(typedQueryFixture);
846
+ const gemLibPath = path.resolve(
847
+ path.dirname(new URL(import.meta.url).pathname),
848
+ "..",
849
+ "..",
850
+ "..",
851
+ "lib",
852
+ );
853
+ const fixtureJson = JSON.stringify(typedQueryFixture);
854
+ const script =
855
+ 'require "ruact/server_functions/codegen"; ' +
856
+ 'require "json"; ' +
857
+ `print Ruact::ServerFunctions::Codegen.render(JSON.parse('${fixtureJson}'))`;
858
+ const rubyOutput = execFileSync("ruby", ["-I", gemLibPath, "-e", script], {
859
+ encoding: "utf8",
860
+ });
861
+ expect(jsOutput).toBe(rubyOutput);
862
+ });
863
+
864
+ it("leaves action + empty modules byte-identical to the pre-13.4 output (regression)", () => {
865
+ const ACTION_ONLY = {
866
+ version: 2,
867
+ generated_at: "2026-06-26T00:00:00Z",
868
+ functions: [
869
+ { js_identifier: "createPost", kind: "action", http_method: "POST", path: "/posts", segments: [] },
870
+ ],
871
+ };
872
+ const expectedAction =
873
+ "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n" +
874
+ "// Source: Rails route table (version 2)\n" +
875
+ "// Generated at: 2026-06-26T00:00:00Z\n" +
876
+ 'import { _makeServerFunction } from "ruact/server-functions-runtime";\n' +
877
+ "\n" +
878
+ "export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) " +
879
+ "& ((formData: FormData) => Promise<void>) =\n" +
880
+ ' _makeServerFunction({ method: "POST", path: "/posts", segments: [] });\n' +
881
+ "\n" +
882
+ 'export { revalidate } from "ruact/server-functions-runtime";\n';
883
+ expect(render(ACTION_ONLY)).toBe(expectedAction);
884
+
885
+ const EMPTY = { version: 2, generated_at: "2026-06-26T00:00:00Z", functions: [] };
886
+ const expectedEmpty =
887
+ "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n" +
888
+ "// Source: Rails route table (version 2)\n" +
889
+ "// Generated at: 2026-06-26T00:00:00Z\n" +
890
+ 'import { _makeServerFunction } from "ruact/server-functions-runtime";\n' +
891
+ "\n" +
892
+ "// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n" +
893
+ "void _makeServerFunction;\n" +
894
+ "\n" +
895
+ 'export { revalidate } from "ruact/server-functions-runtime";\n';
896
+ expect(render(EMPTY)).toBe(expectedEmpty);
897
+ });
898
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "//": "Story 13.4 — type-check ONLY the emitted-accessor type-test fixtures (AC6). `noEmit` + `strict`; resolves the runtime import to the bundled .d.ts so the emitted `_makeQuery` call shape is exercised. Run via `npm run typecheck`.",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noEmit": true,
6
+ "skipLibCheck": true,
7
+ "target": "ES2020",
8
+ "module": "ESNext",
9
+ "moduleResolution": "Bundler",
10
+ "exactOptionalPropertyTypes": false,
11
+ "types": [],
12
+ "baseUrl": ".",
13
+ "paths": {
14
+ "ruact/server-functions-runtime": ["../ruact-server-functions-runtime/index.d.ts"]
15
+ }
16
+ },
17
+ "include": ["type-tests/**/*.ts"]
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "//": "Story 14.4 (AC5) — type-check the GENERATED design-system-AGNOSTIC `<Model>{List,Form,DeleteDialog}.tsx` fixtures in ISOLATION against an ambient stub (type-tests/scaffold/agnostic/ambient.d.ts) that declares ONLY React + @/.ruact/server-functions and NO @/components/ui/* module. A stray shadcn import would fail to resolve → the typecheck goes red, proving the agnostic default needs zero design-system resolution. Mirrors tsconfig.scaffold.json (the shadcn isolated typecheck), but scoped to the agnostic subdir so the two ambient stubs never merge. Run via `npm run typecheck`.",
3
+ "//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `search` accessor (FR99), the required `posts` prop, the controlled DeleteDialog contract — are all EXPLICITLY annotated and stay fully checked under `strict`.",
4
+ "compilerOptions": {
5
+ "strict": true,
6
+ "noImplicitAny": false,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "target": "ES2020",
10
+ "module": "ESNext",
11
+ "moduleResolution": "Bundler",
12
+ "jsx": "preserve",
13
+ "exactOptionalPropertyTypes": false,
14
+ "types": [],
15
+ "baseUrl": "."
16
+ },
17
+ "include": ["type-tests/scaffold/agnostic/**/*.ts", "type-tests/scaffold/agnostic/**/*.tsx"]
18
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "//": "Story 10.2 (AC7 / Task 5) — type-check the GENERATED `<Model>List.tsx` fixture in ISOLATION against the ambient shadcn / @tanstack/react-table / server-functions stubs (type-tests/scaffold/ambient.d.ts). `jsx: preserve` type-checks JSX without `lib: dom` / @types/react. Kept separate from tsconfig.json so the strict no-JSX 13.4 type-tests stay untouched. Run via `npm run typecheck` (which runs both).",
3
+ "//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `search` accessor (FR99), ColumnDef<Row>, the required `posts` prop — are all EXPLICITLY annotated and stay fully checked under `strict`.",
4
+ "compilerOptions": {
5
+ "strict": true,
6
+ "noImplicitAny": false,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "target": "ES2020",
10
+ "module": "ESNext",
11
+ "moduleResolution": "Bundler",
12
+ "jsx": "preserve",
13
+ "exactOptionalPropertyTypes": false,
14
+ "types": [],
15
+ "baseUrl": "."
16
+ },
17
+ "include": ["type-tests/scaffold/**/*.ts", "type-tests/scaffold/**/*.tsx"],
18
+ "//exclude": "Story 14.4 — the agnostic fixtures live under type-tests/scaffold/agnostic/ with their OWN ambient stub (NO @/components/ui module). Exclude them here so the shadcn ambient (this config) and the agnostic ambient never merge into one compilation; they are type-checked separately via tsconfig.scaffold-agnostic.json.",
19
+ "exclude": ["type-tests/scaffold/agnostic/**"]
20
+ }
@@ -0,0 +1,23 @@
1
+ // Story 13.4 (AC3 / Task 6) — proves the emitted typed-`params` `export const`
2
+ // type-checks against the runtime `_makeQuery` declaration WITHOUT a cast (so the
3
+ // runtime `.d.ts` needs no widening). This is a faithful copy of what
4
+ // `renderQueryExportV2` emits for a kwargs query: the typed signature annotation
5
+ // on the `const`, assigned the `_makeQuery({ path, kind })` factory result.
6
+
7
+ import { _makeQuery } from "ruact/server-functions-runtime";
8
+
9
+ // Emitted verbatim by the codegen (no cast, no `any`):
10
+ export const searchUsers: (params: {
11
+ term: string | number | boolean | null;
12
+ limit?: string | number | boolean | null;
13
+ }) => Promise<unknown> = _makeQuery({ path: "/q/searchUsers", kind: "query" });
14
+
15
+ // And the no-kwargs shape:
16
+ export const categories: () => Promise<unknown> = _makeQuery({
17
+ path: "/q/categories",
18
+ kind: "query",
19
+ });
20
+
21
+ // The emitted accessors are callable at their declared shapes.
22
+ void searchUsers({ term: "ada", limit: 10 });
23
+ void categories();