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
@@ -5,104 +5,25 @@ require "tmpdir"
5
5
 
6
6
  module Ruact
7
7
  module ServerFunctions
8
- RSpec.describe Snapshot, :story_8_0a do
9
- let(:posts_controller) { Class.new { def self.name = "PostsController" } }
10
- let(:cats_controller) { Class.new { def self.name = "CategoriesController" } }
11
- let(:actions) { Registry.new }
12
- let(:queries) { Registry.new }
13
- let(:frozen_time) { Time.utc(2026, 5, 13, 12, 34, 56) }
14
-
15
- describe ".dump (Story 8.0a — pure snapshot builder)" do
16
- it "returns the empty payload when both registries are empty" do
17
- snapshot = described_class.dump(actions, queries, now: frozen_time)
18
-
19
- expect(snapshot).to eq(
20
- version: 1,
21
- generated_at: "2026-05-13T12:34:56Z",
22
- functions: []
23
- )
24
- end
25
-
26
- it "merges action + query entries into a single functions array" do
27
- actions.register(:create_post, kind: :action, controller: posts_controller)
28
- queries.register(:categories, kind: :query, controller: cats_controller)
29
-
30
- snapshot = described_class.dump(actions, queries, now: frozen_time)
31
-
32
- expect(snapshot[:functions]).to contain_exactly(
33
- { "ruby_symbol" => "create_post", "js_identifier" => "createPost",
34
- "kind" => "action", "controller" => "PostsController" },
35
- { "ruby_symbol" => "categories", "js_identifier" => "categories",
36
- "kind" => "query", "controller" => "CategoriesController" }
37
- )
38
- end
39
-
40
- it "sorts functions by ruby_symbol for deterministic output (Story 8.0a)" do
41
- actions.register(:zeta, kind: :action, controller: posts_controller)
42
- actions.register(:alpha, kind: :action, controller: posts_controller)
43
- queries.register(:mike, kind: :query, controller: posts_controller)
44
-
45
- symbols = described_class.dump(actions, queries, now: frozen_time)[:functions]
46
- .map { |fn| fn["ruby_symbol"] }
47
- expect(symbols).to eq(%w[alpha mike zeta])
48
- end
49
-
50
- it "stringifies controller class names; falls back to nil for nil controllers" do
51
- actions.register(:demo_ping, kind: :action, controller: nil)
52
- snapshot = described_class.dump(actions, queries, now: frozen_time)
53
- expect(snapshot[:functions].first["controller"]).to be_nil
54
- end
8
+ RSpec.describe Snapshot, :story_9_3 do
9
+ let(:frozen_time) { Time.utc(2026, 5, 13, 12, 34, 56) }
10
+ let(:entries) do
11
+ [
12
+ { "js_identifier" => "createPost", "kind" => "action", "http_method" => "POST",
13
+ "path" => "/posts", "segments" => [], "controller" => "posts", "action" => "create" }
14
+ ]
55
15
  end
56
16
 
57
- describe ".functions_payload — cross-registry collision (Chunk1 Blocker 2026-05-13)" do
58
- it "raises Ruact::ConfigurationError shaped per AC7 when an action and a query " \
59
- "share a JS identifier (Re-run patch 2026-05-14 — message prefix aligned with " \
60
- "within-registry collision)",
61
- :aggregate_failures do
62
- actions.register(:foo, kind: :action, controller: posts_controller)
63
- queries.register(:foo, kind: :query, controller: cats_controller)
64
- expect { described_class.functions_payload(actions, queries) }
65
- .to raise_error(Ruact::ConfigurationError) do |error|
66
- # AC7 exact shape: rake wraps to "[ruact] error: server-function naming collision: :foo (in PostsController) and :foo (in CategoriesController) both map to JS identifier \"foo\""
67
- expect(error.message).to start_with("server-function naming collision:")
68
- expect(error.message).to include(":foo (in PostsController)")
69
- expect(error.message).to include(":foo (in CategoriesController)")
70
- expect(error.message).to include('"foo"')
71
- # No "kind:" annotation per Pass-2 patch 2026-05-14
72
- expect(error.message).not_to include("kind:")
73
- end
74
- end
75
-
76
- it "raises when different Ruby symbols cross-collide via the naming bridge" do
77
- # `:foo_bar` action + `:foo__bar` query both → "fooBar"
78
- actions.register(:foo_bar, kind: :action, controller: posts_controller)
79
- queries.register(:foo__bar, kind: :query, controller: cats_controller)
80
- expect { described_class.functions_payload(actions, queries) }
81
- .to raise_error(Ruact::ConfigurationError, /server-function naming collision.*"fooBar"/m)
82
- end
83
-
84
- it "raises when both registries contain the same Ruby symbol with matching " \
85
- "js_identifier (one js_identifier per emitted export is the design intent)" do
86
- actions.register(:categories, kind: :action, controller: posts_controller)
87
- queries.register(:categories, kind: :query, controller: cats_controller)
88
- expect { described_class.functions_payload(actions, queries) }
89
- .to raise_error(Ruact::ConfigurationError, /server-function naming collision/)
90
- end
91
- end
92
-
93
- describe ".functions_payload (Story 8.0a — fingerprint surface)" do
94
- it "excludes the generated_at timestamp so registry-equivalent calls match" do
95
- actions.register(:create_post, kind: :action, controller: posts_controller)
96
-
97
- first = described_class.functions_payload(actions, queries)
98
- sleep 0.01
99
- second = described_class.functions_payload(actions, queries)
100
-
101
- expect(first).to eq(second)
17
+ describe ".dump_v2" do
18
+ it "wraps entries in a version-2 snapshot Hash" do
19
+ snap = described_class.dump_v2(entries, now: frozen_time)
20
+ expect(snap[:version]).to eq(2)
21
+ expect(snap[:generated_at]).to eq(frozen_time.iso8601)
22
+ expect(snap[:functions]).to eq(entries)
102
23
  end
103
24
  end
104
25
 
105
- describe ".generate! (Story 8.0a — write-if-changed orchestration)" do
26
+ describe ".generate_v2! (write-if-changed)" do
106
27
  around do |example|
107
28
  Dir.mktmpdir do |dir|
108
29
  @tmpdir = dir
@@ -112,142 +33,61 @@ module Ruact
112
33
 
113
34
  let(:path) { File.join(@tmpdir, "server-functions.json") }
114
35
 
115
- it "writes the file on first call and returns true" do
116
- result = described_class.generate!(
117
- action_registry: actions, query_registry: queries, path: path, now: frozen_time
118
- )
119
-
120
- expect(result).to be(true)
121
- expect(File).to exist(path)
36
+ it "writes a version-2 bridge on first call" do
37
+ expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
122
38
  parsed = JSON.parse(File.read(path))
123
- expect(parsed.fetch("version")).to eq(1)
124
- expect(parsed.fetch("functions")).to eq([])
39
+ expect(parsed.fetch("version")).to eq(2)
40
+ expect(parsed.fetch("functions").first.fetch("js_identifier")).to eq("createPost")
125
41
  end
126
42
 
127
- it "does NOT rewrite the file when the registry is unchanged " \
128
- "(Story 8.0a pitfall #1 mitigation)", :aggregate_failures do
129
- actions.register(:create_post, kind: :action, controller: posts_controller)
130
- described_class.generate!(action_registry: actions, query_registry: queries,
131
- path: path, now: frozen_time)
132
-
133
- original_mtime = File.mtime(path)
134
- original_bytes = File.read(path)
135
- original_time = JSON.parse(original_bytes)["generated_at"]
136
- sleep 1.05 # ensure mtime resolution is exceeded if we DID rewrite
137
-
138
- result = described_class.generate!(
139
- action_registry: actions, query_registry: queries,
140
- path: path, now: Time.now.utc # different now
141
- )
142
-
143
- expect(result).to be(false)
144
- expect(File.mtime(path)).to eq(original_mtime)
145
- expect(JSON.parse(File.read(path))["generated_at"]).to eq(original_time)
43
+ it "short-circuits when entries are unchanged (no timestamp churn)" do
44
+ described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
45
+ expect(described_class.generate_v2!(entries: entries, path: path, now: Time.now.utc)).to be(false)
146
46
  end
147
47
 
148
- it "rewrites the file when a function is added" do
149
- described_class.generate!(action_registry: actions, query_registry: queries,
150
- path: path, now: frozen_time)
151
- actions.register(:create_post, kind: :action, controller: posts_controller)
152
-
153
- result = described_class.generate!(
154
- action_registry: actions, query_registry: queries, path: path, now: frozen_time
155
- )
156
-
157
- expect(result).to be(true)
158
- expect(JSON.parse(File.read(path))["functions"].size).to eq(1)
48
+ it "rewrites when entries change" do
49
+ described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
50
+ more = entries + [{ "js_identifier" => "destroyPost", "kind" => "action",
51
+ "http_method" => "DELETE", "path" => "/posts/:id",
52
+ "segments" => ["id"], "controller" => "posts", "action" => "destroy" }]
53
+ expect(described_class.generate_v2!(entries: more, path: path, now: frozen_time)).to be(true)
159
54
  end
160
55
 
161
56
  it "creates the parent directory if missing" do
162
- nested = File.join(@tmpdir, "deep", "nest", "server-functions.json")
163
- expect do
164
- described_class.generate!(action_registry: actions, query_registry: queries,
165
- path: nested, now: frozen_time)
166
- end
167
- .to change { File.exist?(nested) }.from(false).to(true)
57
+ nested = File.join(@tmpdir, "deep", "nested", "server-functions.json")
58
+ expect(described_class.generate_v2!(entries: entries, path: nested, now: frozen_time)).to be(true)
59
+ expect(File.exist?(nested)).to be(true)
168
60
  end
169
61
 
170
62
  it "recovers from a corrupted existing file by overwriting it" do
171
- File.write(path, "not json")
172
- result = described_class.generate!(
173
- action_registry: actions, query_registry: queries, path: path, now: frozen_time
174
- )
175
- expect(result).to be(true)
176
- expect { JSON.parse(File.read(path)) }.not_to raise_error
177
- end
178
-
179
- it "rewrites the file when the on-disk snapshot has a different version " \
180
- "(Chunk1 Major 2026-05-13 — version mismatch must not be treated as unchanged)" do
181
- File.write(path, JSON.pretty_generate(version: 99, generated_at: "2020-01-01T00:00:00Z", functions: []))
182
- result = described_class.generate!(
183
- action_registry: actions, query_registry: queries, path: path, now: frozen_time
184
- )
185
- expect(result).to be(true)
186
- expect(JSON.parse(File.read(path))["version"]).to eq(1)
187
- end
188
- end
189
-
190
- describe "route-driven (v2) snapshot (Story 9.3)", :story_9_3 do
191
- let(:entries) do
192
- [
193
- { "js_identifier" => "createPost", "kind" => "action", "http_method" => "POST",
194
- "path" => "/posts", "segments" => [], "controller" => "posts", "action" => "create" }
195
- ]
196
- end
197
-
198
- describe ".dump_v2" do
199
- it "wraps entries in a version-2 snapshot Hash" do
200
- snap = described_class.dump_v2(entries, now: frozen_time)
201
- expect(snap[:version]).to eq(2)
202
- expect(snap[:generated_at]).to eq(frozen_time.iso8601)
203
- expect(snap[:functions]).to eq(entries)
204
- end
205
- end
206
-
207
- describe ".generate_v2! (write-if-changed)" do
208
- around do |example|
209
- Dir.mktmpdir do |dir|
210
- @tmpdir = dir
211
- example.run
212
- end
63
+ File.write(path, "{ not json")
64
+ expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
65
+ expect(JSON.parse(File.read(path)).fetch("version")).to eq(2)
66
+ end
67
+
68
+ # Story 13.4 the new typed-query metadata must survive the JSON
69
+ # round-trip unchanged AND keep the file byte-stable (no churn on a
70
+ # second pass with identical entries).
71
+ context "with typed-query params metadata (Story 13.4)", :story_13_4 do
72
+ let(:query_entries) do
73
+ [
74
+ { "js_identifier" => "searchUsers", "kind" => "query", "http_method" => "GET",
75
+ "path" => "/q/searchUsers", "segments" => [], "accepts_params" => true,
76
+ "params" => [{ "name" => "term", "required" => true },
77
+ { "name" => "limit", "required" => false }],
78
+ "params_rest" => false, "controller" => "CatalogQuery", "action" => "search_users" }
79
+ ]
213
80
  end
214
81
 
215
- let(:path) { File.join(@tmpdir, "server-functions.next.json") }
216
-
217
- it "writes a version-2 bridge on first call" do
218
- expect(described_class.generate_v2!(entries: entries, path: path, now: frozen_time)).to be(true)
82
+ it "round-trips the params metadata unchanged" do
83
+ described_class.generate_v2!(entries: query_entries, path: path, now: frozen_time)
219
84
  parsed = JSON.parse(File.read(path))
220
- expect(parsed.fetch("version")).to eq(2)
221
- expect(parsed.fetch("functions").first.fetch("js_identifier")).to eq("createPost")
222
- end
223
-
224
- it "short-circuits when entries are unchanged (no timestamp churn)" do
225
- described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
226
- expect(described_class.generate_v2!(entries: entries, path: path, now: Time.now.utc)).to be(false)
227
- end
228
-
229
- it "rewrites when entries change" do
230
- described_class.generate_v2!(entries: entries, path: path, now: frozen_time)
231
- more = entries + [{ "js_identifier" => "destroyPost", "kind" => "action",
232
- "http_method" => "DELETE", "path" => "/posts/:id",
233
- "segments" => ["id"], "controller" => "posts", "action" => "destroy" }]
234
- expect(described_class.generate_v2!(entries: more, path: path, now: frozen_time)).to be(true)
235
- end
236
- end
237
-
238
- describe ".v1_declarations? (Decision-#6 ownership primitive)" do
239
- it "is false when both registries are empty" do
240
- expect(described_class.v1_declarations?(Registry.new, Registry.new)).to be(false)
241
- end
242
-
243
- it "is true when the action registry has any entry" do
244
- actions.register(:create_post, kind: :action, controller: posts_controller)
245
- expect(described_class.v1_declarations?(actions, Registry.new)).to be(true)
85
+ expect(parsed.fetch("functions")).to eq(query_entries)
246
86
  end
247
87
 
248
- it "is true when the query registry has any entry" do
249
- queries.register(:categories, kind: :query, controller: cats_controller)
250
- expect(described_class.v1_declarations?(Registry.new, queries)).to be(true)
88
+ it "is byte-stable: a second pass with identical entries writes nothing" do
89
+ described_class.generate_v2!(entries: query_entries, path: path, now: frozen_time)
90
+ expect(described_class.generate_v2!(entries: query_entries, path: path, now: Time.now.utc)).to be(false)
251
91
  end
252
92
  end
253
93
  end
@@ -2,9 +2,9 @@
2
2
 
3
3
  # Story 9.1 — request-cycle spec for the Story 8.4 structured-error chain
4
4
  # RE-ANCHORED on the `Ruact::Server` concern (its final, v2 home). Replaces
5
- # `server_functions/endpoint_controller_rescue_spec.rb` (removed in the same
6
- # commit — AC5: no orphan salvage, no double coverage). Pins, against REAL
7
- # host-controller routes (no `/__ruact/fn/` anywhere):
5
+ # the removed v1 endpoint rescue spec (AC5: no orphan salvage, no double
6
+ # coverage). Pins, against REAL host-controller routes (no synthetic gem
7
+ # endpoint anywhere):
8
8
  #
9
9
  # - structured payload on function-call requests: discriminator, baseline
10
10
  # fields, dev extras, validation_errors, suggestion (inventory A1, A2,
@@ -56,7 +56,7 @@ I18n.backend.load_translations
56
56
  require_relative "controller_request_spec" unless defined?(ControllerRequestSpecSupport)
57
57
 
58
58
  module ServerRescueSpecSupport
59
- # The exact wire shape the 8.1 runtime sends on every `_makeRef` fetch:
59
+ # The exact wire shape the runtime sends on every server-function fetch:
60
60
  # JSON body + `Accept: application/json` — the Bucket-2 / function-call
61
61
  # request shape the concern's predicate keys on.
62
62
  FUNCTION_CALL_HEADERS = {
@@ -177,8 +177,8 @@ module ServerRescueSpecSupport
177
177
  # Host with real CSRF enforcement — the concern's explicit
178
178
  # InvalidAuthenticityToken registration must render the structured 403
179
179
  # for function-call requests (inventory A13). Forgery is flipped on
180
- # per-example via `allow_forgery_protection` (class-level), mirroring the
181
- # v1 spec pattern — but on the HOST controller now, not EndpointController.
180
+ # per-example via `allow_forgery_protection` (class-level), on the host
181
+ # controller that includes Ruact::Server.
182
182
  class ForgeryServerController < ActionController::Base
183
183
  include Ruact::Server
184
184
 
@@ -10,8 +10,8 @@
10
10
  # host until then).
11
11
  # - AC2 / D3: the `__ruact_function_call?` predicate matrix — the single
12
12
  # named discrimination point Story 9.2 reuses. Keyed on the raw `Accept`
13
- # header containing `application/json` (what the 8.1 runtime sends on
14
- # every `_makeRef` fetch); deliberately NOT `request.format`, which is
13
+ # header containing `application/json` (what the runtime sends on
14
+ # every server-function fetch); deliberately NOT `request.format`, which is
15
15
  # influenced by path extensions and `params[:format]`.
16
16
  #
17
17
  # Request-cycle behavior (error chain, upload guard) is pinned by
@@ -55,15 +55,14 @@ RSpec.describe Ruact::Server, :story_9_1 do
55
55
  expect(before_filters.first).to eq(:__ruact_enforce_upload_limit!)
56
56
  end
57
57
 
58
- it "adds NO public instance methods to the host (predicate + handlers are private)" do
58
+ it "adds only the Story 13.3 `ruact_errors` opt-in helper as a public method (predicate + handlers stay private)" do
59
+ # Story 9.1's installation surface was "nothing public"; Story 13.3 (FR98)
60
+ # adds exactly ONE deliberate public method — the `ruact_errors(record)`
61
+ # opt-in validation-error collector / reader (AC2). The dual-bucket
62
+ # predicate, error handlers, and flash helpers all remain private.
59
63
  added = ServerConcernUnitSupport::ConcernController.public_instance_methods -
60
64
  ServerConcernUnitSupport::PlainController.public_instance_methods
61
- expect(added).to eq([])
62
- end
63
-
64
- it "registers nothing in the v1 registries (codegen exposure is Story 9.3, not 9.1)" do
65
- expect(Ruact.action_registry.entries).to be_empty
66
- expect(Ruact.query_registry.entries).to be_empty
65
+ expect(added).to eq([:ruact_errors])
67
66
  end
68
67
 
69
68
  it "keeps INHERITED host rescue_from handlers more recent than its own (review patch)",
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 13.2 (FR96) — unit specs for the signed-reference primitive:
4
+ # Ruact.signed_global_id (outbound) + Ruact.locate_signed (inbound). Covers the
5
+ # four epic AC cases (round-trip / tampered / expired / wrong-purpose) plus the
6
+ # loud-omission guards that keep an unscoped or silently-non-expiring token
7
+ # unconstructible.
8
+
9
+ require "spec_helper"
10
+ require "global_id"
11
+ require "active_support"
12
+ require "active_support/core_ext/integer/time"
13
+ require "active_support/core_ext/numeric/time"
14
+ require "active_support/message_verifier"
15
+
16
+ # A GlobalID-locatable test record: globalid keys off `class.name` + `#id` and
17
+ # resolves via `class.find(id)`. Top-level + constantizable so the locator can
18
+ # reach it (mirrors how query_request_spec defines its host classes top-level).
19
+ class SignedRefSpecPost
20
+ include GlobalID::Identification
21
+
22
+ attr_reader :id
23
+
24
+ def initialize(id)
25
+ @id = id.to_s
26
+ self.class.store[@id] = self
27
+ end
28
+
29
+ def self.store
30
+ @store ||= {}
31
+ end
32
+
33
+ def self.find(id)
34
+ store.fetch(id.to_s)
35
+ end
36
+ end
37
+
38
+ module Ruact # rubocop:disable Style/OneClassPerFile
39
+ RSpec.describe SignedReferences, :story_13_2 do
40
+ let(:record) { SignedRefSpecPost.new("7") }
41
+
42
+ before do
43
+ # globalid signing needs an app name + a verifier secret. Set both as
44
+ # test infrastructure (no other spec uses globalid, so the global state
45
+ # is inert elsewhere).
46
+ GlobalID.app = "ruact-test"
47
+ SignedGlobalID.verifier = ActiveSupport::MessageVerifier.new("a" * 64)
48
+ end
49
+
50
+ describe ".signed_global_id + .locate_signed round-trip (AC: round-trip)" do
51
+ it "mints a token that resolves back to the same record for a matching purpose" do
52
+ token = Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour)
53
+
54
+ expect(token).to be_a(String)
55
+ expect(token).not_to eq("7") # a signed token, not the raw id
56
+ expect(token).to include("--") # MessageVerifier signature separator
57
+ located = Ruact.locate_signed(token, for: :post_edit)
58
+ expect(located.id).to eq("7")
59
+ end
60
+ end
61
+
62
+ describe ".locate_signed rejection cases" do
63
+ let(:token) { Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour) }
64
+
65
+ it "rejects a tampered token (AC: tampered)" do
66
+ expect { Ruact.locate_signed("#{token}tamper", for: :post_edit) }
67
+ .to raise_error(Ruact::InvalidSignedGlobalIDError)
68
+ end
69
+
70
+ it "rejects an expired token (AC: expired)" do
71
+ expired = Ruact.signed_global_id(record, for: :post_edit, expires_in: -1.hour)
72
+ expect { Ruact.locate_signed(expired, for: :post_edit) }
73
+ .to raise_error(Ruact::InvalidSignedGlobalIDError)
74
+ end
75
+
76
+ it "rejects a wrong-purpose token (AC: wrong-for)" do
77
+ expect { Ruact.locate_signed(token, for: :post_delete) }
78
+ .to raise_error(Ruact::InvalidSignedGlobalIDError)
79
+ end
80
+
81
+ it "rejects a nil / garbage token" do
82
+ expect { Ruact.locate_signed(nil, for: :post_edit) }
83
+ .to raise_error(Ruact::InvalidSignedGlobalIDError)
84
+ expect { Ruact.locate_signed("not-a-token", for: :post_edit) }
85
+ .to raise_error(Ruact::InvalidSignedGlobalIDError)
86
+ end
87
+
88
+ it "does not echo the raw token in the rejection message (no leak)" do
89
+ Ruact.locate_signed("#{token}tamper", for: :post_edit)
90
+ rescue Ruact::InvalidSignedGlobalIDError => e
91
+ expect(e.message).not_to include(token)
92
+ end
93
+ end
94
+
95
+ describe "loud-omission guards (never a silent insecure default)" do
96
+ it "raises when no purpose is given and none is configured" do
97
+ expect { Ruact.signed_global_id(record, expires_in: 1.hour) }
98
+ .to raise_error(Ruact::Error, /purpose/)
99
+ end
100
+
101
+ it "raises on an explicit for: nil (an unscoped token is never allowed)" do
102
+ expect { Ruact.signed_global_id(record, for: nil, expires_in: 1.hour) }
103
+ .to raise_error(Ruact::Error, /purpose/)
104
+ end
105
+
106
+ it "raises when expiry is omitted and none is configured" do
107
+ expect { Ruact.signed_global_id(record, for: :post_edit) }
108
+ .to raise_error(Ruact::Error, /expir/i)
109
+ end
110
+
111
+ it "honors an explicit expires_in: nil as a deliberate non-expiring token" do
112
+ token = Ruact.signed_global_id(record, for: :post_edit, expires_in: nil)
113
+ expect(Ruact.locate_signed(token, for: :post_edit).id).to eq("7")
114
+ end
115
+
116
+ it "rejects expires_in: false (globalid would silently treat it as non-expiring)" do
117
+ expect { Ruact.signed_global_id(record, for: :post_edit, expires_in: false) }
118
+ .to raise_error(Ruact::Error, /expiry must be/)
119
+ end
120
+
121
+ it "rejects a bare Integer expires_in (globalid would crash on #from_now)" do
122
+ expect { Ruact.signed_global_id(record, for: :post_edit, expires_in: 3600) }
123
+ .to raise_error(Ruact::Error, /expiry must be/)
124
+ end
125
+
126
+ it "rejects a configured default of false (the second silent non-expiring path)" do
127
+ Ruact.configure { |c| c.signed_global_id_default_expires_in = false }
128
+ expect { Ruact.signed_global_id(record, for: :post_edit) }
129
+ .to raise_error(Ruact::Error, /expiry must be/)
130
+ end
131
+
132
+ it "raises a clear error for a non-GlobalID-locatable value" do
133
+ expect { Ruact.signed_global_id("plain-string", for: :post_edit, expires_in: 1.hour) }
134
+ .to raise_error(Ruact::Error, /GlobalID-locatable/)
135
+ end
136
+
137
+ it "does NOT convert a valid token to a deleted record into InvalidSignedGlobalIDError " \
138
+ "(contract: a gone record is the host's normal finder concern, not a forged token)" do
139
+ token = Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour)
140
+ SignedRefSpecPost.store.delete(record.id) # the row is gone, the token is still valid
141
+
142
+ # the underlying finder raises (KeyError here; ActiveRecord::RecordNotFound in a real app),
143
+ # NOT Ruact::InvalidSignedGlobalIDError — the primitive only owns signature/expiry/purpose.
144
+ expect { Ruact.locate_signed(token, for: :post_edit) }
145
+ .to raise_error(KeyError)
146
+ end
147
+ end
148
+
149
+ describe "configured defaults" do
150
+ it "falls back to signed_global_id_default_purpose / _expires_in when the call omits them" do
151
+ Ruact.configure do |c|
152
+ c.signed_global_id_default_purpose = :app_default
153
+ c.signed_global_id_default_expires_in = 1.hour
154
+ end
155
+
156
+ token = Ruact.signed_global_id(record)
157
+ expect(Ruact.locate_signed(token).id).to eq("7")
158
+ # a token minted under the default purpose must not resolve under another
159
+ expect { Ruact.locate_signed(token, for: :something_else) }
160
+ .to raise_error(Ruact::InvalidSignedGlobalIDError)
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module Ruact
6
+ RSpec.describe StringDistance, :story_13_5 do
7
+ describe ".damerau_levenshtein" do
8
+ it "returns 0 for identical strings" do
9
+ expect(described_class.damerau_levenshtein("postId", "postId")).to eq(0)
10
+ end
11
+
12
+ it "counts an adjacent transposition as a single edit" do
13
+ expect(described_class.damerau_levenshtein("postId", "postdI")).to eq(1)
14
+ end
15
+
16
+ it "handles empty strings" do
17
+ expect(described_class.damerau_levenshtein("", "abc")).to eq(3)
18
+ expect(described_class.damerau_levenshtein("abc", "")).to eq(3)
19
+ end
20
+ end
21
+
22
+ describe ".closest_match" do
23
+ let(:pool) { %w[postId initialCount title] }
24
+
25
+ it "finds the nearest candidate within the threshold (case-insensitive)" do
26
+ expect(described_class.closest_match("postID", pool)).to eq("postId")
27
+ end
28
+
29
+ it "returns nil when nothing is within distance" do
30
+ expect(described_class.closest_match("zzzzzzzz", pool)).to be_nil
31
+ end
32
+
33
+ it "prefers the smallest distance" do
34
+ expect(described_class.closest_match("titel", pool)).to eq("title")
35
+ end
36
+ end
37
+ end
38
+ end