ruact 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +114 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/ruact/controller.rb +18 -3
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +120 -1
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +149 -0
  11. data/lib/ruact/railtie.rb +14 -3
  12. data/lib/ruact/render_pipeline.rb +8 -1
  13. data/lib/ruact/serializable.rb +98 -6
  14. data/lib/ruact/server.rb +163 -0
  15. data/lib/ruact/server_functions/introspection.rb +81 -0
  16. data/lib/ruact/server_functions.rb +26 -4
  17. data/lib/ruact/testing/component_query.rb +113 -0
  18. data/lib/ruact/testing/flight_extractor.rb +221 -0
  19. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  20. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  21. data/lib/ruact/testing.rb +90 -0
  22. data/lib/ruact/version.rb +1 -1
  23. data/lib/ruact/view_helper.rb +11 -4
  24. data/lib/ruact.rb +1 -0
  25. data/lib/tasks/ruact.rake +55 -2
  26. data/spec/ruact/controller_spec.rb +7 -2
  27. data/spec/ruact/doctor_spec.rb +141 -0
  28. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  29. data/spec/ruact/install_generator_spec.rb +285 -0
  30. data/spec/ruact/manifest_resolver_spec.rb +174 -0
  31. data/spec/ruact/serializable_spec.rb +126 -0
  32. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  33. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  34. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  35. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  36. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  37. data/spec/spec_helper.rb +15 -0
  38. data/spec/support/flight_wire_parser.rb +12 -126
  39. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  40. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  41. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  42. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  43. data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
  44. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  45. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  46. metadata +17 -2
@@ -511,6 +511,141 @@ RSpec.describe Ruact::Doctor do
511
511
  end
512
512
  end
513
513
 
514
+ # --- JSON introspection (Story 15.3, FR107, AC1 + AC3 + AC4) ---
515
+
516
+ describe "#as_json (machine-readable report)", :story_15_3 do
517
+ subject(:doctor) { described_class.new }
518
+
519
+ context "when all checks pass" do
520
+ before do
521
+ make_manifest
522
+ make_controller(with_include: true)
523
+ make_layout(with_sentinel: true)
524
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
525
+ end
526
+
527
+ it "returns a document that round-trips through JSON.parse", :aggregate_failures do
528
+ report = doctor.as_json
529
+ reparsed = JSON.parse(JSON.generate(report))
530
+ expect(reparsed).to eq(report)
531
+ end
532
+
533
+ it "gates the document with the EXPERIMENTAL schema_version (0)" do
534
+ expect(doctor.as_json["schema_version"]).to eq(0)
535
+ expect(doctor.as_json["schema_version"]).to eq(Ruact::Doctor::SCHEMA_VERSION)
536
+ end
537
+
538
+ it "carries every check with name/status/message/remediation keys", :aggregate_failures do
539
+ checks = doctor.as_json["checks"]
540
+ expect(checks.map { |c| c["name"] }).to eq(described_class::CHECKS.map(&:to_s))
541
+ checks.each do |check|
542
+ expect(check.keys).to contain_exactly("name", "status", "message", "remediation")
543
+ expect(check["status"]).to be_a(String)
544
+ expect(check["message"]).to be_a(String)
545
+ end
546
+ end
547
+
548
+ it "reports top-level status 'pass' and exits-zero semantics" do
549
+ expect(doctor.as_json["status"]).to eq("pass")
550
+ end
551
+
552
+ it "emits NO prose to stdout (JSON mode is the document only)" do
553
+ expect { doctor.as_json }.not_to output.to_stdout
554
+ end
555
+ end
556
+
557
+ context "when a check fails" do
558
+ before do
559
+ make_controller(with_include: true)
560
+ make_layout(with_sentinel: true)
561
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
562
+ # manifest is missing → check_manifest fails
563
+ end
564
+
565
+ it "reports top-level status 'fail' (non-zero exit semantics)" do
566
+ expect(doctor.as_json["status"]).to eq("fail")
567
+ end
568
+
569
+ it "carries the separate machine-readable remediation for the failing check" do
570
+ manifest = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }
571
+ expect(manifest["status"]).to eq("fail")
572
+ expect(manifest["remediation"]).to eq("Run vite build (or bin/dev) to generate the client manifest.")
573
+ end
574
+ end
575
+
576
+ context "when a check warns (Rack::Deflater mounted)" do
577
+ before do
578
+ make_manifest
579
+ make_controller(with_include: true)
580
+ make_layout(with_sentinel: true)
581
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
582
+ app = Struct.new(:middleware).new([Struct.new(:name).new("Rack::Deflater")])
583
+ allow(Rails).to receive(:application).and_return(app)
584
+ end
585
+
586
+ it "keeps status 'pass' — a :warn does not fail — but exposes the warn check" do
587
+ report = doctor.as_json
588
+ expect(report["status"]).to eq("pass")
589
+ flight = report["checks"].find { |c| c["name"] == "flight_middleware" }
590
+ expect(flight["status"]).to eq("warn")
591
+ expect(flight["remediation"]).to include("Exclude text/x-component from compression")
592
+ end
593
+ end
594
+
595
+ it "keeps the message byte-identical to the human tuple (remediation is separate)" do
596
+ _status, human_message = doctor.send(:check_manifest)
597
+ json_message = doctor.as_json["checks"].find { |c| c["name"] == "manifest" }["message"]
598
+ expect(json_message).to eq(human_message)
599
+ end
600
+ end
601
+
602
+ describe "#results / #passed? (shared compute, Story 15.3)", :story_15_3 do
603
+ subject(:doctor) { described_class.new }
604
+
605
+ before do
606
+ make_manifest
607
+ make_controller(with_include: true)
608
+ make_layout(with_sentinel: true)
609
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
610
+ end
611
+
612
+ it "returns one tuple per check, index-aligned with CHECKS" do
613
+ expect(doctor.results.length).to eq(described_class::CHECKS.length)
614
+ end
615
+
616
+ it "passed? is true when all checks pass/warn" do
617
+ expect(doctor.passed?).to be true
618
+ end
619
+
620
+ it "runs each check exactly once (as_json does not double-run check_vite's socket)" do
621
+ # check_vite opens a socket via TCPSocket.new; a single as_json must open it
622
+ # exactly once — proving #results is computed once, not per consumer.
623
+ doctor.as_json
624
+ expect(TCPSocket).to have_received(:new).once
625
+ end
626
+ end
627
+
628
+ describe "#run stays byte-identical after the #results refactor (Story 15.3, AC4a)", :story_15_3 do
629
+ subject(:doctor) { described_class.new }
630
+
631
+ before do
632
+ make_manifest
633
+ make_controller(with_include: true)
634
+ make_layout(with_sentinel: true)
635
+ allow(TCPSocket).to receive(:new).and_return(instance_double(TCPSocket, close: nil))
636
+ end
637
+
638
+ it "prints exactly the header + one format_result line per check (no JSON/prose leak), returns true" do
639
+ # Reconstruct the expected output from the SAME format_result the human
640
+ # path uses — proves #run still emits header + one glyph line per check
641
+ # (the optional 3rd remediation element is ignored) and nothing else.
642
+ lines = doctor.results.map { |status, message| doctor.send(:format_result, status, message) }.join("\n")
643
+ expected = "[ruact] Health check\n#{lines}\n"
644
+
645
+ expect { expect(doctor.run).to be true }.to output(expected).to_stdout
646
+ end
647
+ end
648
+
514
649
  describe "Rake task definition (Story 5.12)", :story_5_12 do
515
650
  # Loads gem/lib/tasks/ruact.rake into a fresh Rake::Application so the
516
651
  # task table is isolated from any other spec that may have loaded tasks.
@@ -549,5 +684,11 @@ RSpec.describe Ruact::Doctor do
549
684
  # actions is a list of Procs; just assert at least one is attached
550
685
  expect(task.actions).not_to be_empty
551
686
  end
687
+
688
+ it "defines Rake::Task['ruact:routes'] with an action (Story 15.3)", :story_15_3 do
689
+ task = rake_app.lookup("ruact:routes")
690
+ expect(task).not_to be_nil
691
+ expect(task.actions).not_to be_empty
692
+ end
552
693
  end
553
694
  end
@@ -116,6 +116,151 @@ module Ruact
116
116
  end
117
117
  end
118
118
 
119
+ # Story 15.2 (FR106) — children inside a PascalCase component tag (a matching
120
+ # closing tag) fail LOUDLY at preprocess time instead of degrading silently.
121
+ describe "loud children error (FR106)", :story_15_2 do
122
+ def run(source, identifier: nil)
123
+ described_class.transform(source, identifier: identifier)
124
+ end
125
+
126
+ it "raises on `<Card>Hello</Card>` naming component + fix (AC#1)" do
127
+ expect { run("<Card>Hello</Card>") }
128
+ .to raise_error(ChildrenNotSupportedError) do |e|
129
+ expect(e.message).to include("Card")
130
+ expect(e.message).to include("children are not supported")
131
+ expect(e.message).to include("pass content as a prop")
132
+ expect(e.message).to include("<Card content={...} />")
133
+ end
134
+ end
135
+
136
+ it "is a Ruact::PreprocessorError (AC#1 — subclass IS-A base)" do
137
+ expect { run("<Card>Hello</Card>") }.to raise_error(Ruact::PreprocessorError)
138
+ end
139
+
140
+ it "raises on an empty pair `<Card></Card>` (AC#1)" do
141
+ expect { run("<Card></Card>") }.to raise_error(ChildrenNotSupportedError, /Card/)
142
+ end
143
+
144
+ it "raises on multi-line children (AC#1)" do
145
+ source = "<Card>\n <p>hi</p>\n</Card>"
146
+ expect { run(source) }.to raise_error(ChildrenNotSupportedError, /children are not supported/)
147
+ end
148
+
149
+ it "raises on a paired tag that also holds a nested component" do
150
+ expect { run("<Card><Button /></Card>") }
151
+ .to raise_error(ChildrenNotSupportedError, /<Card>/)
152
+ end
153
+
154
+ it "names the supplied identifier and the correct line for a non-first-line pair (AC#4)" do
155
+ source = "line1\nline2\n<Card>Hello</Card>"
156
+ expect { run(source, identifier: "app/views/posts/show.html.erb") }
157
+ .to raise_error(ChildrenNotSupportedError) do |e|
158
+ expect(e.message).to include("app/views/posts/show.html.erb:3")
159
+ end
160
+ end
161
+
162
+ it "reports the opening tag's line, honoring attributes on the opening tag" do
163
+ expect { run("<Card variant={:wide}>x</Card>", identifier: "t.erb") }
164
+ .to raise_error(ChildrenNotSupportedError, /t\.erb:1/)
165
+ end
166
+
167
+ # Regression (Codex Round 1, Patch 1): a MULTI-LINE Suspense opening tag
168
+ # must not shift the reported line — the `<Card>` below is physically on
169
+ # line 5 and must report :5 (Suspense is masked newline-for-newline).
170
+ it "reports the exact source line even after a multi-line Suspense opening" do
171
+ source = <<~ERB
172
+ <Suspense
173
+ fallback="loading"
174
+ delay="2.5">
175
+ </Suspense>
176
+ <Card>Hello</Card>
177
+ ERB
178
+ expect { run(source, identifier: "t.erb") }
179
+ .to raise_error(ChildrenNotSupportedError, /t\.erb:5/)
180
+ end
181
+
182
+ # Regression (Codex Round 2, Patch 1): a `</Dialog>` living inside an ERB
183
+ # island (Ruby string/comment) must NOT be mistaken for a real component
184
+ # closing tag — a valid bare `<Dialog open={true}>` stays valid.
185
+ it "does not false-pair a bare opening with a `</Tag>` inside an ERB island" do
186
+ source = %(<Dialog open={true}>\n<% x = "</Dialog>" %>)
187
+ expect { run(source) }.not_to raise_error
188
+ end
189
+
190
+ it "still fires when the closing tag is real ERB body, not inside `<% %>`" do
191
+ expect { run("<Card><%= @body %></Card>") }
192
+ .to raise_error(ChildrenNotSupportedError, /Card/)
193
+ end
194
+
195
+ # Regression (Codex Round 2, Patch 2): many bare non-self-closing openings
196
+ # with no close must stay linear (single-pass stack scan) and silent (D3).
197
+ it "stays silent and does not blow up on many bare unclosed openings" do
198
+ source = "<Dialog open={true}>\n" * 5000
199
+ expect { run(source) }.not_to raise_error
200
+ end
201
+
202
+ # Regression (Codex Round 3, Patch 1): stray/unmatched PascalCase closing
203
+ # tags (no preceding matching open) are literal text — never an error — and
204
+ # must stay linear (per-name O(1) lookup, no `rindex`). Correctness pin;
205
+ # perf verified live, not timed here (avoids a flaky benchmark spec).
206
+ it "does not raise on stray closing tags with no matching opening" do
207
+ source = "</Card>\n" * 5000
208
+ expect { run(source) }.not_to raise_error
209
+ end
210
+
211
+ it "still raises when a real opening precedes the stray closes" do
212
+ source = "<Card>x</Card>\n#{'</Card>' * 100}"
213
+ expect { run(source) }.to raise_error(ChildrenNotSupportedError, /Card/)
214
+ end
215
+
216
+ # AC#2 regression — the loud error must NOT fire on any valid pattern.
217
+ describe "does NOT fire on valid patterns (AC#2 byte-identical)" do
218
+ it "self-closing tag, no props" do
219
+ expect { run("<Button />") }.not_to raise_error
220
+ end
221
+
222
+ it "self-closing tag with props" do
223
+ expect { run("<LikeButton postId={@post.id} />") }.not_to raise_error
224
+ end
225
+
226
+ it "bare non-self-closing opening tag with NO closing tag (`<Dialog open={true}>`)" do
227
+ expect { run("<Dialog open={true}>") }.not_to raise_error
228
+ end
229
+
230
+ it "nested-brace prop value" do
231
+ expect { run("<Select options={Category.all.map { |c| c.id }} />") }.not_to raise_error
232
+ end
233
+
234
+ it "multiple self-closing components" do
235
+ expect { run('<Button /> and <Badge label={"hello"} />') }.not_to raise_error
236
+ end
237
+
238
+ it "mixed HTML around a self-closing component" do
239
+ source = %(<div class="container">\n <h1>Hi</h1>\n <LikeButton postId={1} />\n</div>)
240
+ expect { run(source) }.not_to raise_error
241
+ end
242
+
243
+ it "emits byte-identical output for `<Dialog open={true}>`" do
244
+ expect(run("<Dialog open={true}>"))
245
+ .to eq(%(<%= __ruact_component__("Dialog", { "open" => true }) %>))
246
+ end
247
+ end
248
+
249
+ # AC#3 — Suspense children are the ONE legitimate paired PascalCase tag and
250
+ # must never trip the error (normalized to <ruact-suspense> in Step 1).
251
+ describe "Suspense children never trip the error (AC#3)" do
252
+ it "does not raise for `<Suspense><Spinner /></Suspense>`" do
253
+ expect { run(%(<Suspense fallback="loading"><Spinner /></Suspense>)) }.not_to raise_error
254
+ end
255
+
256
+ it "normalizes Suspense to <ruact-suspense> exactly as today" do
257
+ result = run(%(<Suspense fallback="loading"><Spinner /></Suspense>))
258
+ expect(result).to include(%(data-ruact-fallback="loading"))
259
+ expect(result).to include("</ruact-suspense>")
260
+ end
261
+ end
262
+ end
263
+
119
264
  # Story 13.5 (FR100) — preprocess-time component-contract validation, wired
120
265
  # through an injectable registry seam (a stub responding to +contract_for+).
121
266
  describe "component contract validation", :story_13_5 do
@@ -359,6 +359,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
359
359
  gen.create_server_functions_directory
360
360
  gen.append_gitignore_entries
361
361
  gen.create_vite_config
362
+ gen.create_agents_md
362
363
  end.not_to raise_error
363
364
  end
364
365
 
@@ -371,6 +372,7 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
371
372
  expect(File.read(File.join(app_root, ".gitignore")))
372
373
  .to include("app/javascript/.ruact/server-functions.ts")
373
374
  expect(File).to exist(File.join(app_root, "vite.config.js"))
375
+ expect(File).to exist(File.join(app_root, "AGENTS.md"))
374
376
  end
375
377
  end
376
378
  end
@@ -889,4 +891,287 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
889
891
  end
890
892
  end
891
893
  end
894
+
895
+ # Story 15.1 (FR105) — `ruact:install` emits an AGENTS.md teaching coding
896
+ # agents the ruact conventions, traps, and verification commands. The ruact
897
+ # content is delimited by explicit markers so re-runs are idempotent, a
898
+ # user-authored AGENTS.md is appended to (never clobbered), and --force
899
+ # refreshes ONLY the marked section. Content assertions grep stable tokens
900
+ # (commands, file paths, API names) — never full-file snapshots, since later
901
+ # stories (15.2/15.3/15.4) deliberately evolve the prose.
902
+ describe "install generator — AGENTS.md emission (Story 15.1 — FR105)", :story_15_1 do
903
+ require "stringio"
904
+ require "generators/ruact/install/install_generator"
905
+
906
+ let(:app_root) { Dir.mktmpdir("ruact_install_1501") }
907
+ let(:agents_md_path) { File.join(app_root, "AGENTS.md") }
908
+ let(:begin_marker) { "<!-- ruact:begin -->" }
909
+ let(:end_marker) { "<!-- ruact:end -->" }
910
+ let(:template_path) do
911
+ File.expand_path("../../lib/generators/ruact/install/templates/AGENTS.md.tt", __dir__)
912
+ end
913
+
914
+ after { FileUtils.rm_rf(app_root) }
915
+
916
+ def build_generator(root, opts = {})
917
+ Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
918
+ end
919
+
920
+ def silently
921
+ original = $stdout
922
+ $stdout = StringIO.new
923
+ yield
924
+ ensure
925
+ $stdout = original
926
+ end
927
+
928
+ # Captures and returns the generator's say/say_status stdout for text asserts.
929
+ def capture_stdout
930
+ original = $stdout
931
+ $stdout = StringIO.new
932
+ yield
933
+ $stdout.string
934
+ ensure
935
+ $stdout = original
936
+ end
937
+
938
+ describe "fresh emission (AC#1)" do
939
+ it "creates AGENTS.md delimited by the ruact markers", :aggregate_failures do
940
+ silently { build_generator(app_root).create_agents_md }
941
+
942
+ expect(File).to exist(agents_md_path)
943
+ content = File.read(agents_md_path)
944
+ expect(content).to start_with(begin_marker)
945
+ expect(content.rstrip).to end_with(end_marker)
946
+ end
947
+
948
+ it "covers the model, codegen, queries and verification areas", :aggregate_failures do
949
+ silently { build_generator(app_root).create_agents_md }
950
+ content = File.read(agents_md_path)
951
+
952
+ # The verb rule (mutations = non-GET routed actions on Ruact::Server)
953
+ expect(content).to include("Ruact::Server")
954
+ expect(content).to include("non-GET")
955
+ # Route-driven codegen: ground-truth file + regenerate command
956
+ expect(content).to include("app/javascript/.ruact/server-functions.ts")
957
+ expect(content).to include("bin/rails ruact:server_functions:generate")
958
+ # Queries (reads)
959
+ expect(content).to include("Ruact::Query")
960
+ expect(content).to include("app/queries/")
961
+ expect(content).to include("ruact_queries")
962
+ expect(content).to include("useQuery")
963
+ # Verification commands (the only two that exist today)
964
+ expect(content).to include("bin/rails ruact:doctor")
965
+ # Docs pointers
966
+ expect(content).to include("https://ruact.dev")
967
+ expect(content).to include("llms.txt")
968
+ end
969
+
970
+ it "covers the five traps and the safety areas", :aggregate_failures do
971
+ silently { build_generator(app_root).create_agents_md }
972
+ content = File.read(agents_md_path)
973
+
974
+ # Trap 1 — children unsupported / self-closing only
975
+ expect(content).to include("self-closing")
976
+ expect(content).to include("children")
977
+ # Trap 2 — Ruby (not JS) inside {} props
978
+ expect(content).to include("Ruby, not JavaScript")
979
+ # Trap 3 — Accept-header dual shape (exact application/json)
980
+ expect(content).to include("Accept")
981
+ expect(content).to include("application/json")
982
+ # Trap 4 — ruact_errors fall-through
983
+ expect(content).to include("ruact_errors")
984
+ expect(content).to include("fall-through")
985
+ # Trap 5 — name derivation from routes
986
+ expect(content).to include("createPost")
987
+ expect(content).to include("ruact_function_name")
988
+ # Serialization allowlist
989
+ expect(content).to include("ruact_props")
990
+ expect(content).to include("strict_serialization")
991
+ # SGID helpers (purpose + expiry required grain)
992
+ expect(content).to include("Ruact.signed_global_id")
993
+ expect(content).to include("Ruact.locate_signed")
994
+ end
995
+
996
+ it "makes no claim before its artifact (AC#4 — content honesty)", :aggregate_failures do
997
+ silently { build_generator(app_root).create_agents_md }
998
+ content = File.read(agents_md_path)
999
+
1000
+ # (The loud children `PreprocessorError` is Story 15.2's shipped
1001
+ # artifact, and the `--json` introspection commands are Story 15.3's —
1002
+ # both may now be claimed; see the `:story_15_2` / `:story_15_3` guards
1003
+ # below that PIN each claim's presence. Story 15.4's public render
1004
+ # helpers are now shipped too — see the `:story_15_4` guard below.)
1005
+ #
1006
+ # tsc does not run in a fresh install (no typescript devDep / tsconfig) —
1007
+ # it may only appear conditionally phrased (D1).
1008
+ expect(content).to match(/if your app has typescript tooling/i) if content.include?("tsc")
1009
+ # Volatile strings must not be quoted: no gem version pins.
1010
+ expect(content).not_to match(/\b0\.0\.\d+\b/)
1011
+ end
1012
+
1013
+ # Story 15.2 (D2) — now that the loud children error is a shipped artifact,
1014
+ # trap #1 truthfully claims it fails LOUDLY (was "fails silently" in 15.1).
1015
+ it "trap #1 claims the loud PreprocessorError (Story 15.2 D2)", :aggregate_failures, :story_15_2 do
1016
+ silently { build_generator(app_root).create_agents_md }
1017
+ content = File.read(agents_md_path)
1018
+
1019
+ expect(content).to include("PreprocessorError")
1020
+ expect(content).to match(/fail LOUDLY/i)
1021
+ expect(content).not_to match(/fail silently/i)
1022
+ end
1023
+
1024
+ # Story 15.3 (D2) — now that the `--json` introspection surface is a shipped
1025
+ # artifact, the "Verify your work" section references it (relaxes the 15.1
1026
+ # honesty guard above). This PINS the claim AND its EXPERIMENTAL framing so
1027
+ # the template never presents the JSON shape as a stable contract.
1028
+ it "references --json introspection as EXPERIMENTAL (15.3 D2)", :aggregate_failures, :story_15_3 do
1029
+ silently { build_generator(app_root).create_agents_md }
1030
+ content = File.read(agents_md_path)
1031
+
1032
+ expect(content).to include("ruact:routes -- --json")
1033
+ expect(content).to include("ruact:doctor")
1034
+ expect(content).to include("Append `-- --json`")
1035
+ expect(content).to match(/EXPERIMENTAL/)
1036
+ expect(content).to include("schema_version")
1037
+ end
1038
+
1039
+ # Story 15.4 (D6) — now that the public render-assertion helpers are a
1040
+ # shipped artifact (FR108), the "Verify your work" section references them.
1041
+ # This PINS the claim (name + explicit require + the JSON-vs-Flight rule)
1042
+ # so it stays consistent with `website/public/llms.txt`.
1043
+ it "references the ruact/testing render helpers (15.4 D6)", :aggregate_failures, :story_15_4 do
1044
+ silently { build_generator(app_root).create_agents_md }
1045
+ content = File.read(agents_md_path)
1046
+
1047
+ expect(content).to include(%(require "ruact/testing"))
1048
+ expect(content).to include("have_ruact_component")
1049
+ expect(content).to include("JSON.parse(response.body)")
1050
+ end
1051
+ end
1052
+
1053
+ describe "idempotency (AC#2 — double-run zero diff)" do
1054
+ it "leaves the file byte-identical on a second run and reports a skip", :aggregate_failures do
1055
+ silently { build_generator(app_root).create_agents_md }
1056
+ first = File.read(agents_md_path)
1057
+
1058
+ output = capture_stdout { build_generator(app_root).create_agents_md }
1059
+
1060
+ expect(File.read(agents_md_path)).to eq(first)
1061
+ expect(output).to include("skip")
1062
+ end
1063
+ end
1064
+
1065
+ describe "append mode (AC#2 — user-authored AGENTS.md)" do
1066
+ let(:user_content) { "# My app\n\nUse pnpm here. Never touch config/secrets.yml.\n" }
1067
+
1068
+ it "appends the marked section preserving every pre-existing user byte", :aggregate_failures do
1069
+ File.write(agents_md_path, user_content)
1070
+
1071
+ silently { build_generator(app_root).create_agents_md }
1072
+ content = File.read(agents_md_path)
1073
+
1074
+ expect(content).to start_with(user_content)
1075
+ expect(content.scan(begin_marker).count).to eq(1)
1076
+ expect(content.scan(end_marker).count).to eq(1)
1077
+ expect(content).to include("\n\n#{begin_marker}")
1078
+ end
1079
+
1080
+ it "blank-line separates the section when the user file lacks a trailing newline" do
1081
+ File.write(agents_md_path, "# My app — no trailing newline")
1082
+
1083
+ silently { build_generator(app_root).create_agents_md }
1084
+
1085
+ expect(File.read(agents_md_path))
1086
+ .to include("# My app — no trailing newline\n\n#{begin_marker}")
1087
+ end
1088
+
1089
+ it "appending then re-running is idempotent (skip, zero diff)" do
1090
+ File.write(agents_md_path, user_content)
1091
+ silently { build_generator(app_root).create_agents_md }
1092
+ appended = File.read(agents_md_path)
1093
+
1094
+ silently { build_generator(app_root).create_agents_md }
1095
+
1096
+ expect(File.read(agents_md_path)).to eq(appended)
1097
+ end
1098
+ end
1099
+
1100
+ describe "--force refresh (AC#2 — marked-block-only)" do
1101
+ it "replaces ONLY the between-marker content, never user bytes outside", :aggregate_failures do
1102
+ silently { build_generator(app_root).create_agents_md }
1103
+ section = File.read(agents_md_path)
1104
+ stale = section.sub("## Five traps", "## STALE CONTENT FROM AN OLDER GEM")
1105
+ File.write(agents_md_path, "# mine, above\n\n#{stale}\n# mine, below\n")
1106
+
1107
+ silently { build_generator(app_root, { force: true }).create_agents_md }
1108
+ content = File.read(agents_md_path)
1109
+
1110
+ expect(content).to start_with("# mine, above\n")
1111
+ expect(content).to end_with("# mine, below\n")
1112
+ expect(content).to include("## Five traps")
1113
+ expect(content).not_to include("STALE CONTENT FROM AN OLDER GEM")
1114
+ expect(content.scan(begin_marker).count).to eq(1)
1115
+ end
1116
+
1117
+ it "creates the file under --force when none exists" do
1118
+ silently { build_generator(app_root, { force: true }).create_agents_md }
1119
+ expect(File.read(agents_md_path)).to include(begin_marker)
1120
+ end
1121
+ end
1122
+
1123
+ # Codex R1 — an incomplete marker state is ambiguous in BOTH directions
1124
+ # (appending would duplicate content next to a stray marker; replacing
1125
+ # would guess the range), so the only byte-safe move is warn + no-op.
1126
+ describe "broken marker pair (byte-safety)" do
1127
+ [
1128
+ ["a begin marker without its end marker",
1129
+ "# mine\n\n<!-- ruact:begin -->\ntruncated ruact section, no end marker\n"],
1130
+ ["an end marker without its begin marker",
1131
+ "# mine\n\nstray tail of a ruact section\n<!-- ruact:end -->\n"],
1132
+ ["an end marker preceding the begin marker",
1133
+ "# mine\n\n<!-- ruact:end -->\nreversed\n<!-- ruact:begin -->\n"],
1134
+ # Codex R2 — a VALID pair plus stray extra markers is still ambiguous:
1135
+ # skip/refresh would leave (or eat past) the unmatched marker.
1136
+ ["a stray end marker before a valid pair",
1137
+ "<!-- ruact:end -->\nstray\n\n<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n"],
1138
+ ["a stray begin marker after a valid pair",
1139
+ "<!-- ruact:begin -->\nsection\n<!-- ruact:end -->\n\nstray\n<!-- ruact:begin -->\n"],
1140
+ ["two complete marker pairs",
1141
+ "<!-- ruact:begin -->\none\n<!-- ruact:end -->\n\n<!-- ruact:begin -->\ntwo\n<!-- ruact:end -->\n"]
1142
+ ].each do |(label, broken)|
1143
+ it "warns and leaves the file untouched with #{label} (no --force)", :aggregate_failures do
1144
+ File.write(agents_md_path, broken)
1145
+
1146
+ output = capture_stdout { build_generator(app_root).create_agents_md }
1147
+
1148
+ expect(File.read(agents_md_path)).to eq(broken)
1149
+ expect(output).to include("warn")
1150
+ end
1151
+
1152
+ it "warns and leaves the file untouched with #{label} (--force)", :aggregate_failures do
1153
+ File.write(agents_md_path, broken)
1154
+
1155
+ output = capture_stdout { build_generator(app_root, { force: true }).create_agents_md }
1156
+
1157
+ expect(File.read(agents_md_path)).to eq(broken)
1158
+ expect(output).to include("warn")
1159
+ end
1160
+ end
1161
+ end
1162
+
1163
+ describe "action ordering + template budget" do
1164
+ it "defines create_agents_md BEFORE install_javascript_dependencies" do
1165
+ klass = Ruact::Generators::InstallGenerator
1166
+ line = ->(name) { klass.instance_method(name).source_location.last }
1167
+ expect(line.call(:create_agents_md)).to be < line.call(:install_javascript_dependencies)
1168
+ end
1169
+
1170
+ # AC#3 tripwire — the ~150-line budget is the epic's scope probe; the
1171
+ # tripwire's tolerance is 160 (the tripwire's, not the product contract's).
1172
+ it "keeps the template body at or under 160 lines" do
1173
+ expect(File.read(template_path).lines.count).to be <= 160
1174
+ end
1175
+ end
1176
+ end
892
1177
  end