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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ecde11a9eeeaa534f73c9aba3a1c15a1bb44bbe1035467dad11b6f8517085aa
4
- data.tar.gz: 7b3077daa2fe5cef2189b40c575df223cc960b49e8ace3ce5cebf8b61afd9e29
3
+ metadata.gz: f9a45065af0d01daed8548084a9667fb066f9e8c19a685f99b0f101a8e825ca0
4
+ data.tar.gz: 99206ecd7d61a44b4cdfe52bb5b40b046f66e2938f7bf060e603b98ef17971b8
5
5
  SHA512:
6
- metadata.gz: 2696f4a816b237e124465604bc04a2d5a54804601d413bf907429376efb8b72d17ff876f013fb4491df47c4b231a696f7213c8e431e5924f4a504e7d2b7a1214
7
- data.tar.gz: e1cf6a62a6e0d4e5d061b4dae97fe71d47df9137db0a3cc1581cb1056282a0b757a69eff7d1a7981ae3a419ee9d79be938871cb1f636d612e2a6affd026a809f
6
+ metadata.gz: d303e5aa55881c70b88a6a7f132450712c8f35adb7b00f0172d3503784dbe82b1373ae7ecc30db05a98832f2c2154134e49bbba43ba403497cdc61b90401b91a
7
+ data.tar.gz: 8b8fcc40cca7167dece277333fdd56562a62800d150af0be85a50033fc6a2ea3dc8aeebf7650dbb220891f73a843a80d2b0becd1f1d7483b4a2afa6af8a1404e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.8] - 2026-07-11
11
+
12
+ ### Changed
13
+
14
+ - **`ManifestError` message is now in English (was Portuguese)** ([Story 15.0](../_bmad-output/implementation-artifacts/15-0-agent-era-hygiene-pass.md)). When the Vite dev server is unreachable AND no `public/react-client-manifest.json` exists on disk, `Ruact::ManifestResolver` now raises `[ruact] Vite dev server unreachable at <url> and no react-client-manifest.json found at <path> — run `bin/dev`.` — same raise site, same `ManifestError` class, same HTTP→file fallback order, same diagnostic content (dev-server URL + manifest path + the `bin/dev` fix); only the language changed. Error messages are prompts — agents (and humans) regex-match them — so the stable tokens (`Vite dev server`, `react-client-manifest.json`, `bin/dev`) are preserved and now spec-pinned.
15
+
16
+ ### Added
17
+
18
+ - **`ruact_props` now works directly on an ActiveRecord model — loud check deferred from class-load to first serialize for lazy attributes (Story 13.7)** ([Story 13.7](../_bmad-output/implementation-artifacts/13-7-ruact-props-on-activerecord-models.md)). `include Ruact::Serializable; ruact_props :id, :title` on an `ActiveRecord` model used to raise `ArgumentError: method 'title' is not defined` at **class-load**, because ActiveRecord defines its attribute reader methods **lazily** (on first instance access) — so the eager `method_defined?` check fired before the reader existed, even for a real column. The check is now **hybrid**: a **PORO** is still validated eagerly at class-load (byte-identical — a typo still raises immediately), while for a **lazy-attribute (ActiveRecord) class** the not-yet-defined names are recorded and their loud check is **deferred to the first `ruact_serialize`** (via `respond_to?` on the instance, where the DB is up and the reader exists). **The loud-omission guarantee is preserved** (Epic 13.1/13.2 spine): a bogus/typo'd prop still raises the same clean `ArgumentError` — only the *timing* moved for AR (first render of that model instead of boot). No boot-time DB dependency is introduced (`attribute_names`/`column_names` would need a live connection at class-load — rejected), the gem stays **single-dep `nokogiri`** (the ActiveRecord discriminator `defined?(ActiveRecord::Base) && self < ActiveRecord::Base` references the constant only when the host already defined it), and there is **no new config key, no serializer-pipeline change** (an AR model that includes the module already dispatched to `serialize_serializable` → `ruact_serialize`), and **`strict_serialization` is unaffected** (an AR `Serializable` model serializes identically under strict true/false, as a PORO does). This reverses the doc-only guidance shipped in the AGENTS.md/`llms.txt` serialization-contract fix (gem PR #48): `ruact_props` on an AR model is now a first-class pattern again; the manual row hash (`{ id: p.id, title: p.title }`) remains the equivalent for a no-model / partial shape.
19
+
20
+ - **Auto-revalidate opt-in: one `await` folds a mutation and its in-place refresh into a single call (FR110)** ([Story 15.6](../_bmad-output/implementation-artifacts/15-6-auto-revalidate-opt-in.md)). A mutation whose result is shown *elsewhere* on the page has always needed a manual two-step — `await createPost(fd); await revalidate()` — and the second call is easy to forget, leaving a stale page that reads like a silent bug. There are now two runtime-only, opt-in ways to fold the second call in: an app-wide default via `configureRuactRuntime({ autoRevalidate: true })` (mirrors the existing `defaultHeaders` config), and a per-call `withRefresh(accessor)` wrapper (`await withRefresh(createPost)(fd)`) that forces the refresh for one call and **wins over** the global default. When opted in, a **successful, non-redirecting** mutation `await`s an in-place Flight refresh of the current path (the existing `revalidate()` machinery) **before** the promise resolves, then resolves with the action's JSON result. This is **pure client-side composition** of the two requests that already exist (the POST-JSON mutation + the GET-Flight refresh): **no new wire shape and no inbound Flight deserialization — the serialize-only invariant is untouched.** A `$redirect` response **wins**: the redirect is followed and the refresh is skipped (the destination already re-renders; refreshing the old path would be wrong). A **failed** mutation throws before any refresh runs, so no refresh happens. With no router installed, the descriptive `revalidate()` "wire `setupRouter()`" error surfaces (it is not swallowed) — so a mutation that succeeds but whose refresh rejects **rejects the returned promise**, consistent with `revalidate()`'s loud-by-default stance. Opt-out (default) behavior is **byte-identical** to before, and the generated `.ruact/server-functions.ts` is unchanged — `withRefresh` is imported directly from `ruact/server-functions-runtime` (like `configureRuactRuntime`), so the codegen output and the Ruby↔JS byte-parity fixtures are untouched.
21
+
22
+ - **Dev-only `[ruact]` log line naming the negotiated response shape per request (FR109)** ([Story 15.5](../_bmad-output/implementation-artifacts/15-5-response-shape-legibility-docs-and-dev-log.md)). The single most confusing thing about a `Ruact::Server` action is that the same action, at the same URL, answers different body shapes depending only on the request's `Accept` header + verb — never on the Ruby. In **development only**, `Ruact::Server` now emits exactly one `[ruact]` line per ruact-negotiated request naming the chosen bucket and the deciding signal — e.g. `[ruact] PostsController#create — Accept: application/json + POST → function-call JSON` — so the dual-bucket negotiation is observable at runtime without a debugger. It covers the function-call-JSON bucket (including its `204` and `$redirect` sub-shapes) and, for a page render on a controller that also includes `Ruact::Controller`, the Flight-page and HTML-shell shapes. Log-only: response body, status, and headers are byte-identical in every environment. It is SILENT outside development and on a plain Rails action on the same controller (a response ruact did not negotiate). Mirrors the F6 warning: a private, dev-gated `after_action` beside it, no new config key. The log vocabulary matches the new canonical caller→shape table in the server-actions docs. Coverage is `Ruact::Server`-only for now — queries (separate `GET /q` dispatch) and page-only controllers are documented in the table but not yet logged (deferred).
23
+
24
+ - **Public server-side render-assertion helpers: `have_ruact_component` (FR108)** ([Story 15.4](../_bmad-output/implementation-artifacts/15-4-server-side-render-assertion-helpers.md)). A host request/controller spec can now prove a page rendered a given component with given props against the decoded Flight tree — no browser, no brittle string spelunking on the wire bytes. Load it explicitly (`require "ruact/testing"` from your `spec_helper`/`rails_helper` — it is NOT auto-loaded by `require "ruact"`, so a production boot carries no RSpec dependency), then assert `expect(response).to have_ruact_component("PostList")` or `...have_ruact_component("PostList").with_props(a_hash_including("posts" => ...))`. It accepts an ActionDispatch/Rack response (its `.body` is read) or a raw String, in either page shape — a raw `text/x-component` body or the HTML shell embedding `__FLIGHT_DATA`. The component name resolves against the import row the wire actually carries (its export name or module basename); props are asserted in their serialized wire form (string keys, serialized values) via `===`/`hash_including`. Passing a `Ruact::Server` function-call/query response (plain JSON) raises a clear `Ruact::Testing::NotAFlightResponseError` pointing you at `JSON.parse(response.body)` instead. This is a STABLE public matcher that **wraps, not forks**, the internal Story-7.5 structural parser/diff — those were promoted onto the shipped load path as `Ruact::Testing::FlightWireParser` / `Ruact::Testing::FlightStructureDiff`, and the gem's own `Ruact::Spec` matchers now delegate to that single implementation (one source of truth, no drift). New docs "Testing" page covers all three response shapes (pages, function calls, queries).
25
+
26
+ - **Machine-readable introspection: `ruact:doctor -- --json` and `ruact:routes -- --json` (FR107)** ([Story 15.3](../_bmad-output/implementation-artifacts/15-3-machine-readable-introspection-doctor-routes-json.md)). A coding agent (or CI gate) can now read the two ground-truth surfaces as JSON instead of parsing terminal prose. `ruact:doctor -- --json` emits one document — `schema_version`, an overall `status`, and every health check as `name`/`status`/`message`/`remediation` (a separate, nullable machine-readable fix) — reusing the exact same check results the human path prints (no double-run) and preserving the exit code (0 when all checks pass or warn, 1 on any failure). The new `ruact:routes` task emits the accessor/route table — each entry's `accessor` name, `kind` (action or query), `verb`, `path`, `segments`, and declared `params` (queries from their keyword arguments, actions from their required path segments) — derived from the very same `RouteSource` + `QuerySource` collectors codegen consumes (a shared `Ruact::ServerFunctions.introspect` combine point), so the introspection can never drift from what codegen emits; it writes no bridge or TS file (a CI gate can run it without mutating the tree). Bare `ruact:routes` prints a compact human table. The default human `ruact:doctor` output is unchanged. Both JSON documents are marked EXPERIMENTAL and carry `schema_version: 0` — the shape may change without a major bump while the agent-facing surface is iterated, so parsers should gate on it. The flag is passed after a `--` separator (`bin/rails ruact:doctor -- --json`) so neither Rails nor Rake treats it as an option. The emitted `AGENTS.md`/`llms.txt` "Verify your work" section now references both commands, flagged EXPERIMENTAL.
27
+
28
+ - **Children inside a component tag now fail loudly at preprocess time instead of degrading silently (FR106)** ([Story 15.2](../_bmad-output/implementation-artifacts/15-2-loud-children-error-in-erb-preprocessor.md)). ruact PascalCase component tags are self-closing only — a component receives a props Hash, never a children element tree — but the #1 predictable JSX-habit mistake, `<Card>Hello</Card>`, used to **degrade silently**: the opening tag became a `__ruact_component__("Card", {})` call with no children while the inner content leaked into the surrounding HTML and the `</Card>` passed through as literal garbage. The ERB preprocessor now raises a dedicated `Ruact::ChildrenNotSupportedError` (a `Ruact::PreprocessorError`, so it flows through the same dev error overlay) naming the component, the template `file:line`, and the exact fix — pass content as a prop, e.g. a self-closing `<Card content={...} />`. Detection keys on a **matching PascalCase closing tag** (`</Card>`), NOT on "opening tag lacks `/>`", so a bare non-self-closing opening with no closing tag (`<Dialog open={true}>`) stays a valid call. It runs **after** Suspense normalization (Step 1), so `<Suspense>...</Suspense>` — the one legitimate paired PascalCase tag, already lowercased to `<ruact-suspense>` — never trips. Output is byte-identical for every valid pattern. The shipped `AGENTS.md`/`llms.txt` trap #1 wording is updated from "fails silently" to "fails loudly" now that the artifact exists.
29
+
30
+ - **Dev-only `[ruact]` warning when an action registers `ruact_errors` and then renders explicitly on a function-call request (F6)** ([Story 15.0](../_bmad-output/implementation-artifacts/15-0-agent-era-hygiene-pass.md)). The FR98 `errors` injection rides the implicit `default_render`, so an explicit `render json: {...}` on the failed-save branch of a Bucket-2 (function-call) request silently drops the registered validation errors from the JSON body — a legibility trap for agents following the docs literally. In **development only**, `Ruact::Server` now logs exactly one `[ruact]` warning naming the `controller#action`, explaining the opt-out, and pointing at the fix (fall through, bind `errors={ruact_errors}` on a page render, or `redirect_to`). It never fires on the documented-correct patterns: a Bucket-1 `ruact_errors(record); render :new` page render, a `redirect_to` on either bucket, the fall-through itself, or an action that never touches the collector. Log-only — response bodies and statuses are byte-identical in every environment.
31
+
32
+ - **`rails generate ruact:install` now emits an `AGENTS.md` teaching coding agents the ruact conventions, traps, and verification commands (FR105)** ([Story 15.1](../_bmad-output/implementation-artifacts/15-1-agents-md-install-emission-and-llms-txt.md)). ruact is out-of-distribution for every LLM, so a fresh app now ships the agent context in-repo: the mental model (Rails routes as the single source of truth; ERB as the server component; PascalCase self-closing tags), the verb rule, queries/`useQuery`, the codegen ground truth (`app/javascript/.ruact/server-functions.ts` + `bin/rails ruact:server_functions:generate`), the five traps (children, Ruby-in-braces, Accept-header dual shape, `ruact_errors` fall-through, name derivation), the `ruact_props`/`strict_serialization` allowlist, the SGID helpers, and the verification loop (`bin/rails ruact:doctor`). The emitted content is delimited by `<!-- ruact:begin -->`/`<!-- ruact:end -->` markers and the new `create_agents_md` action is **idempotent and append-aware**: no file → create; a user-authored `AGENTS.md` without markers → the marked section is appended with every pre-existing byte preserved; markers present → skip (re-running install is zero-diff); `--force` refreshes **only** the between-marker content, never user bytes outside it. The same compact reference is served by the docs site at `https://ruact.dev/llms.txt` (kept in sync editorially). New `templates/AGENTS.md.tt` + `:story_15_1` specs (including a ≤160-line template-budget tripwire).
33
+
34
+ ## [0.0.7] - 2026-06-30
35
+
36
+ ### Fixed
37
+
38
+ - **Boot-race that 500'd the first request in development — the manifest is now resolved from the Vite dev server over HTTP.** On a fresh app the Railtie's `config.to_prepare` read `public/react-client-manifest.json` **once at boot**, but Rails frequently booted (and read the still-missing file) **before** the Vite dev server wrote it — leaving `Ruact.manifest` `nil`. `public/` is not watched, so `to_prepare` never re-fired, and the first request to a view containing a component hit `nil.reference_for` → a cryptic 500. The bundled Vite plugin now **serves the live in-memory manifest at `GET <vite_dev_server>/__ruact/manifest`** (always fresh, reflects HMR rebuilds, internal `_sourceFile` field stripped), and in **development** the gem resolves the manifest through a new `Ruact::ManifestResolver`: it fetches that endpoint over `Net::HTTP` (~1s timeout, once per render/preprocess — not per component), **falls back** to `public/react-client-manifest.json` on disk when the dev server is down, and otherwise raises a **clear, actionable** error (`Vite dev server inacessível … rode bin/dev`) instead of a `NoMethodError`. **Production is untouched** — it still uses the boot-loaded `Ruact.manifest` (raising at boot if the build is missing); the HTTP fetch is dev-only. The Vite plugin continues to **write** `public/react-client-manifest.json` (prod build + the dev fallback). Both render and the FR100 contract validator resolve through the same path (the validator fails open to no-validation when the manifest is unreachable). The three hardcoded `http://localhost:5173` references in `ruact_js_assets` (the react-refresh preamble, `@vite/client`, and the bootstrap `<script src>`) now honor `Ruact.config.vite_dev_server` for consistency.
39
+
10
40
  ## [0.0.6] - 2026-06-30
11
41
 
12
42
  > **Server functions are route-driven.** A server **mutation** is a normal non-GET
@@ -162,5 +192,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
162
192
  - **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
163
193
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
164
194
 
165
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.6...HEAD
195
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.7...HEAD
196
+ [0.0.7]: https://github.com/luizcg/ruact/releases/tag/v0.0.7
166
197
  [0.0.6]: https://github.com/luizcg/ruact/releases/tag/v0.0.6
@@ -2179,3 +2179,58 @@ TS-AST `Props` parse; a Ruby DSL / JSON sidecar; a new slot runtime; moving
2179
2179
  validation to render time. The **Epic 10 scaffold** consuming contracts and the
2180
2180
  **playground build-fails CI proof** are **Story 13.6** (`epics-phase-2.md`).
2181
2181
  ADR addendum (2026-06-26).
2182
+
2183
+ ### 2026-07-11 — Story 13.7 — `ruact_props` works on ActiveRecord models (deferred loud check for lazy attrs)
2184
+
2185
+ Story 13.7 reopens **option (A)** that `deferred-work.md` #6 parked (the fix-B
2186
+ doc-only route) and makes `include Ruact::Serializable; ruact_props :id, :title`
2187
+ boot **directly on an ActiveRecord model**. It also closes the FR100 producer-side
2188
+ note (*"hoje `ruact_props` valida só o lado produtor, em class-load"*) by making the
2189
+ producer-side check itself AR-aware.
2190
+
2191
+ **The bug.** ActiveRecord defines its attribute reader methods **lazily** (via
2192
+ `define_attribute_methods`, triggered on first instance access / `method_missing`),
2193
+ so at the moment the `ruact_props :title` macro runs, `method_defined?(:title)` is
2194
+ `false` **even for a real column**. The eager class-load check
2195
+ (`serializable.rb`) raised there — an AR model that included the module crashed at
2196
+ boot. (`attribute_names`/`column_names` would tell the truth, but they require a
2197
+ **live DB connection** — with none they raise `ActiveRecord::ConnectionNotDefined`
2198
+ — so any boot-time check based on them adds a class-load DB dependency, a Rails
2199
+ anti-pattern. Rejected.)
2200
+
2201
+ **Decision — HYBRID timing (approach iv, locked by Luiz).** The loud check stays,
2202
+ but **when** it fires now depends on the class:
2203
+
2204
+ - **PORO** — checked **eagerly at class-load** exactly as before. The non-lazy code
2205
+ path is **byte-identical**; a PORO typo still raises `ArgumentError` immediately
2206
+ (the existing PORO spec passes unedited — the C3 regression pin).
2207
+ - **ActiveRecord (lazy-attribute) class** — a name whose reader is not-yet-defined
2208
+ is **recorded** at declaration and its loud check is **deferred to the first
2209
+ `ruact_serialize`**, validated via `instance.respond_to?(attr)` (AR's
2210
+ `respond_to?` returns `true` for a real column, `false` for a bogus name — a
2211
+ genuine typo still raises the **same clean `ArgumentError`**, just at first render
2212
+ of that model instead of at boot). The result is memoized (`respond_to?` runs once
2213
+ per class, not per serialize).
2214
+
2215
+ **Why this satisfies all four constraints.** C1 loud-omission preserved (PORO at
2216
+ boot, AR at first use — still loud, still `ArgumentError`); C2 **no DB dependency at
2217
+ boot** (`respond_to?` runs at first-serialize, when the DB is up); C3 PORO
2218
+ byte-identical; C4 `strict_serialization` untouched (an AR `Serializable` model
2219
+ already dispatches through `serialize_serializable` → `ruact_serialize`, verified
2220
+ under strict true/false).
2221
+
2222
+ **No new dependency / no new config.** The lazy-attribute discriminator is
2223
+ `defined?(ActiveRecord::Base) && self < ActiveRecord::Base` — it references the
2224
+ constant **only when the host already defined it**, so the gem stays single-dep
2225
+ `nokogiri`. **No eager escape hatch** was added (Luiz chose simplicity — no config
2226
+ or macro to force an AR boot check); the natural first-serialize timing is the
2227
+ contract. No serializer-pipeline change (dispatch already routed AR → `ruact_serialize`).
2228
+
2229
+ **Contract note (the one thing that moved).** For an AR model, a typo'd/omitted prop
2230
+ is now caught at the **first render of that model**, not at boot. This is the
2231
+ deliberate, documented cost of not adding a boot DB dependency; loudness itself is
2232
+ unchanged. The AGENTS.md/`llms.txt`/website reversal (`ruact_props` on AR is a
2233
+ first-class pattern again, manual row-hash kept as the no-model equivalent) ships in
2234
+ the same story, resolving `deferred-work.md` #7.
2235
+
2236
+ ADR addendum (2026-07-11).
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "erb"
3
4
  require "pathname"
4
5
  require "rails/generators"
5
6
  require "ruact"
@@ -19,7 +20,10 @@ module Ruact
19
20
  # 7. Creates Procfile.dev + bin/dev (foreman) so the single `bin/dev`
20
21
  # command boots BOTH Rails and the Vite dev server (Story 14.6, Epic 14
21
22
  # DoD — a ruact app needs both processes).
22
- # 8. Runs `npm install` so JavaScript dependencies are ready (FR101);
23
+ # 8. Emits AGENTS.md (marker-delimited, append-aware, idempotent) so coding
24
+ # agents working in the app have ruact's conventions, traps, and
25
+ # verification commands in context by default (Story 15.1, FR105).
26
+ # 9. Runs `npm install` so JavaScript dependencies are ready (FR101);
23
27
  # skippable via --skip-npm.
24
28
  #
25
29
  # Story 14.2 (FR104) — the generator no longer writes a bootstrap entry into
@@ -205,6 +209,40 @@ module Ruact
205
209
  say ""
206
210
  end
207
211
 
212
+ # Story 15.1 (FR105) — emit AGENTS.md so coding agents working in the app
213
+ # have ruact's conventions, traps, and verification commands in context
214
+ # by default. The ruact content is delimited by explicit markers
215
+ # (`<!-- ruact:begin -->` / `<!-- ruact:end -->`) so the action can be
216
+ # append-aware and idempotent:
217
+ #
218
+ # no file → create it (the template IS the section)
219
+ # file without markers → APPEND the marked section, every
220
+ # pre-existing user byte preserved
221
+ # markers present → skip (re-running install is zero-diff)
222
+ # markers present + --force → refresh ONLY the between-marker content —
223
+ # deliberately narrower than the vite.config
224
+ # full-overwrite posture, because a user's
225
+ # AGENTS.md may carry their own project
226
+ # instructions above/below ruact's section.
227
+ #
228
+ # Later stories (15.2 loud children error, 15.3 --json introspection,
229
+ # 15.4 test helpers) evolve the template; `--force` after a gem upgrade
230
+ # is the designed refresh path.
231
+ def create_agents_md
232
+ destination = Pathname(destination_root).join("AGENTS.md")
233
+
234
+ return template("AGENTS.md.tt", "AGENTS.md") unless destination.exist?
235
+
236
+ content = destination.read
237
+ if agents_md_markers_well_formed?(content)
238
+ refresh_or_skip_agents_md_section
239
+ elsif agents_md_markers_broken?(content)
240
+ warn_agents_md_broken_markers
241
+ else
242
+ append_agents_md_section(content)
243
+ end
244
+ end
245
+
208
246
  # Story 14.1 (FR101) — install JavaScript dependencies so a fresh app is
209
247
  # runnable in one command. Runs LAST among the file-producing actions
210
248
  # (after every file is written) so a failure here leaves the generated
@@ -261,6 +299,81 @@ module Ruact
261
299
 
262
300
  private
263
301
 
302
+ # Story 15.1 — the exact marker tokens delimiting the ruact-managed
303
+ # section of AGENTS.md. For a prose file the only safe idempotency key is
304
+ # an explicit marker pair (`append_gitignore_entries`-style exact-line
305
+ # dedup would misfire on edited prose).
306
+ AGENTS_MD_BEGIN_MARKER = "<!-- ruact:begin -->"
307
+ AGENTS_MD_END_MARKER = "<!-- ruact:end -->"
308
+
309
+ # The full marked section, non-greedy so nothing outside the marker pair
310
+ # is ever captured (--force replaces exactly this range).
311
+ AGENTS_MD_SECTION_RE = /#{Regexp.escape(AGENTS_MD_BEGIN_MARKER)}.*?#{Regexp.escape(AGENTS_MD_END_MARKER)}/m
312
+ private_constant :AGENTS_MD_BEGIN_MARKER, :AGENTS_MD_END_MARKER, :AGENTS_MD_SECTION_RE
313
+
314
+ # The rendered ruact section — the full AGENTS.md.tt body, begin marker
315
+ # first line through end marker last line (the template IS the section).
316
+ # Rendered through ERB in the generator's context so the template may
317
+ # interpolate like any other Thor template (today it is fully static).
318
+ def agents_md_section
319
+ @agents_md_section ||= ERB.new(
320
+ File.read(File.expand_path("templates/AGENTS.md.tt", __dir__)),
321
+ trim_mode: "-"
322
+ ).result(binding)
323
+ end
324
+
325
+ # Story 15.1 — append the marked ruact section to a user-authored
326
+ # AGENTS.md, separated by exactly one blank line, preserving every
327
+ # pre-existing byte.
328
+ def append_agents_md_section(existing_content)
329
+ separator = existing_content.end_with?("\n") ? "\n" : "\n\n"
330
+ append_to_file "AGENTS.md", "#{separator}#{agents_md_section}"
331
+ end
332
+
333
+ # Story 15.1 — a WELL-FORMED marker pair is present (the caller matched
334
+ # {AGENTS_MD_SECTION_RE}): skip (idempotent re-run) unless --force, which
335
+ # replaces ONLY the content between (and including) the marker pair.
336
+ # Bytes outside the markers are never touched.
337
+ def refresh_or_skip_agents_md_section
338
+ unless options[:force]
339
+ say_status "skip", "AGENTS.md already carries the ruact section " \
340
+ "(re-run with --force to refresh it)", :yellow
341
+ return
342
+ end
343
+
344
+ # Block form so `\`/`\1` sequences in the section are never treated as
345
+ # backreferences by String#gsub.
346
+ gsub_file("AGENTS.md", AGENTS_MD_SECTION_RE) { agents_md_section.chomp }
347
+ end
348
+
349
+ # Story 15.1 (Codex R1/R2) — the ONLY marker state the action manages:
350
+ # exactly one begin marker, exactly one end marker, begin before end.
351
+ # Anything else — a lone marker, end-before-begin, a stray extra marker
352
+ # alongside a valid pair, multiple pairs — makes the section boundary
353
+ # ambiguous, so it is handled as broken (warn + no-op) rather than
354
+ # guessed at.
355
+ def agents_md_markers_well_formed?(content)
356
+ content.scan(AGENTS_MD_BEGIN_MARKER).length == 1 &&
357
+ content.scan(AGENTS_MD_END_MARKER).length == 1 &&
358
+ AGENTS_MD_SECTION_RE.match?(content)
359
+ end
360
+
361
+ # Story 15.1 (Codex R1) — SOME marker text is present but not in the one
362
+ # well-formed shape above. The boundary is ambiguous in BOTH directions —
363
+ # appending would duplicate content next to stray markers, and replacing
364
+ # would have to guess the range — so the only byte-safe move is to warn
365
+ # and leave every byte alone.
366
+ def agents_md_markers_broken?(content)
367
+ content.include?(AGENTS_MD_BEGIN_MARKER) || content.include?(AGENTS_MD_END_MARKER)
368
+ end
369
+
370
+ def warn_agents_md_broken_markers
371
+ say_status "warn", "AGENTS.md has an incomplete ruact marker pair " \
372
+ "(#{AGENTS_MD_BEGIN_MARKER} … #{AGENTS_MD_END_MARKER}) — " \
373
+ "leaving the file untouched; restore both markers (or delete " \
374
+ "the partial ruact section) and re-run", :yellow
375
+ end
376
+
264
377
  # Story 14.6 (live clean-room fix) — ruact OWNS `bin/dev`. The foreman
265
378
  # launcher is load-bearing: it boots BOTH Rails AND the Vite dev server,
266
379
  # and Vite is what writes `public/react-client-manifest.json`. Rails'
@@ -0,0 +1,159 @@
1
+ <!-- ruact:begin -->
2
+ <!-- Managed by `rails generate ruact:install`. Re-run it with --force after a
3
+ gem upgrade to refresh ONLY this marked section; everything you write
4
+ outside the markers is preserved. Kept in sync manually with the online
5
+ copy at https://ruact.dev/llms.txt -->
6
+
7
+ # ruact — conventions for coding agents
8
+
9
+ ruact renders React Server Components from Rails. ERB templates ARE the server
10
+ components (serialized to the React Flight wire format), Rails routes are the
11
+ single source of truth, and there is no Node server — Vite only bundles the
12
+ client components. ruact is NOT Inertia, react-rails, or Next.js: do not
13
+ transplant their patterns. When unsure, read the generated code (see "Ground
14
+ truth" below) instead of guessing.
15
+
16
+ ## Mental model
17
+
18
+ - A page is a normal Rails controller action rendering a normal `.html.erb`.
19
+ - Interactive components live in `app/javascript/components/` as `"use client"`
20
+ files, mounted from ERB with a PascalCase self-closing tag:
21
+ `<LikeButton postId={@post.id} />`.
22
+ - Mutations are ordinary non-GET controller actions on a controller that does
23
+ `include Ruact::Server`. Reads are `Ruact::Query` classes in `app/queries/`.
24
+ - ruact generates a typed TypeScript module from the route table; React imports
25
+ server functions from `@/.ruact/server-functions`.
26
+
27
+ ## Server functions — the verb rule
28
+
29
+ `include Ruact::Server` makes a controller's routed non-GET actions
30
+ (`POST`/`PATCH`/`PUT`/`DELETE`) callable from React. GET page actions are
31
+ untouched. There is no per-action DSL and no synthetic endpoint: the action IS
32
+ the function, reached at its real route, visible in `bin/rails routes`. The JS
33
+ runtime forwards the CSRF token automatically; keep `include Ruact::Server`
34
+ AFTER `protect_from_forgery` in the ancestor chain.
35
+
36
+ ```ruby
37
+ class PostsController < ApplicationController
38
+ include Ruact::Server
39
+
40
+ def create
41
+ @post = Post.create!(post_params) # ivars become the JSON result
42
+ end
43
+ end
44
+ ```
45
+
46
+ ```tsx
47
+ import { createPost } from "@/.ruact/server-functions";
48
+ const { post } = await createPost({ post: { title: "Hi" } });
49
+ ```
50
+
51
+ ## Reads — queries and useQuery
52
+
53
+ Reads are public methods on `Ruact::Query` subclasses in `app/queries/`,
54
+ mounted in `config/routes.rb` with `ruact_queries CatalogQuery` (one named GET
55
+ route per public method, default prefix `/q`). Call them with `useQuery`:
56
+
57
+ ```tsx
58
+ import { categories, useQuery } from "@/.ruact/server-functions";
59
+ const { data, loading, error } = useQuery(categories);
60
+ ```
61
+
62
+ Query params come from the method's keyword arguments and accept ONLY
63
+ `string | number | boolean | null` (arrays/objects are rejected). Queries run
64
+ the host controller's callback chain (authentication etc.) before the query
65
+ object is instantiated.
66
+
67
+ ## Ground truth — read the generated file
68
+
69
+ `app/javascript/.ruact/server-functions.ts` is regenerated from the route
70
+ table (it is gitignored). It is the authoritative list of every accessor and
71
+ its typed params — READ THAT FILE instead of simulating the name generation.
72
+ Regenerate it after changing routes or queries:
73
+
74
+ bin/rails ruact:server_functions:generate
75
+
76
+ ## Five traps
77
+
78
+ 1. **Component tags take no children.** Client component tags are self-closing
79
+ ONLY: `<Card content={@body} />`, never `<Card>...</Card>`. Children (a
80
+ matching closing tag) fail LOUDLY with a `PreprocessorError` at preprocess
81
+ time — pass content as a prop instead. Sole exception: the built-in
82
+ `<Suspense fallback="...">...</Suspense>` pair.
83
+ 2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
84
+ evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
85
+ no `{...spread}`, no JSX children. Plain unbraced string props are not
86
+ supported either — always use braces.
87
+ 3. **One action, two response shapes.** A `Ruact::Server` non-GET action
88
+ answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
89
+ when the request's `Accept` header is exactly `application/json`, which is
90
+ what every generated-accessor call sends (including
91
+ `<form action={createPost}>`). Any other request shape renders normally
92
+ (a Flight stream for client-side navigation, an HTML page otherwise). You
93
+ cannot infer the response shape from the controller body alone — the
94
+ caller picks it.
95
+ 4. **`ruact_errors` requires fall-through.** On the `if @post.save ... else`
96
+ path, call `ruact_errors(@post)` and let the action END there — ruact's
97
+ implicit render injects `errors: { attribute: [messages] }` into the JSON.
98
+ An explicit `render` on that branch opts out of the injection. In the
99
+ redirect-back flow, `ruact_errors(@post)` then `redirect_to` carries the
100
+ errors through flash to the next render.
101
+ 5. **Accessor names are derived, not declared.** `posts#create` → `createPost`,
102
+ `posts#publish_all` → `publishAllPosts`; query methods camelCase the same
103
+ way (`search_users` → `searchUsers`). Collisions fail loudly at boot;
104
+ break an action tie with `ruact_function_name :action, as: "otherName"` —
105
+ a query tie by renaming the query method. Never guess a name — read
106
+ `app/javascript/.ruact/server-functions.ts`.
107
+
108
+ ## Serialization is an allowlist
109
+
110
+ Only the fields you name cross to the client. `include Ruact::Serializable` +
111
+ `ruact_props :id, :title` is that allowlist — on a PORO OR an ActiveRecord model
112
+ (lazy AR readers checked on first serialize, not class-load; a typo still raises):
113
+
114
+ ```ruby
115
+ class Post < ApplicationRecord
116
+ include Ruact::Serializable
117
+ ruact_props :id, :title # other columns never cross
118
+ end
119
+ ```
120
+
121
+ The manual row hash `{ id: p.id, title: p.title }` is the equivalent for a
122
+ no-model shape. An object with no allowlist warns + falls back to `as_json` in
123
+ dev; production (`strict_serialization`) raises, as does `as_json` returning `self`.
124
+
125
+ ## Record references — signed and explicit
126
+
127
+ Do not put raw record ids in props and trust them later. Mint a scoped signed
128
+ token and resolve it back:
129
+
130
+ ```ruby
131
+ token = Ruact.signed_global_id(@post, for: :editing, expires_in: 1.hour)
132
+ post = Ruact.locate_signed(params[:token], for: :editing) # tampered → 400
133
+ ```
134
+
135
+ The purpose (`for:`) is always required; the expiry must be explicit — a
136
+ duration, or a deliberate `expires_in: nil` for a non-expiring token.
137
+
138
+ ## Verify your work
139
+
140
+ - `bin/rails ruact:doctor` — checks install/config health; exits 1 on failure.
141
+ Append `-- --json` for the same checks as a machine-readable report.
142
+ - `bin/rails ruact:routes -- --json` — the accessor/route table (each accessor's
143
+ name, kind, verb, path, params) as JSON, from the same route table codegen
144
+ reads; bare `ruact:routes` prints a compact human table.
145
+ - Both `--json` outputs are EXPERIMENTAL — the shape may change; gate on their
146
+ `schema_version` field (currently `0`), do not treat it as a stable contract.
147
+ - `bin/rails ruact:server_functions:generate` — regenerates the TS module;
148
+ exits 1 on a naming collision or an invalid name.
149
+ - `bin/dev` — boots Rails AND Vite (both are required: Vite serves the client
150
+ components and writes the client manifest).
151
+ - If your app has TypeScript tooling configured, `npx tsc --noEmit`
152
+ type-checks call sites against the generated accessor types (a fresh
153
+ install does not ship a tsconfig).
154
+ - In request specs, `require "ruact/testing"` then assert a page rendered a
155
+ component — `expect(response).to have_ruact_component("PostList").with_props(including("posts"))`
156
+ — no browser. Function-call/query responses are JSON: `JSON.parse(response.body)`.
157
+
158
+ Docs: https://ruact.dev — compact agent reference at https://ruact.dev/llms.txt
159
+ <!-- ruact:end -->
@@ -36,10 +36,16 @@ module Ruact
36
36
  # only ever invoked internally by `ruact_html_shell`).
37
37
  private :ruact_js_assets, :__ruact_component__
38
38
 
39
- # Returns the boot-time cached manifest (set by Railtie#config.to_prepare).
40
- # No per-request file I/O (AC#6).
39
+ # Resolves the manifest for this render. In PRODUCTION this is the boot-time
40
+ # cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
41
+ # I/O. In DEVELOPMENT it fetches the live manifest from the Vite dev server
42
+ # (falling back to the on-disk file, then a clear error), killing the
43
+ # boot-race where Rails read the missing file before Vite wrote it and every
44
+ # first request 500'd on +nil.reference_for+. Called once per render; the
45
+ # returned manifest is held by RenderPipeline for the whole render (not
46
+ # re-fetched per component). See {Ruact::ManifestResolver}.
41
47
  def ruact_manifest
42
- Ruact.manifest
48
+ ManifestResolver.resolve
43
49
  end
44
50
 
45
51
  # Only activate RSC rendering when the matching .html.erb template exists AND
@@ -151,6 +157,12 @@ module Ruact
151
157
  # (matching the legacy `#from_html` ordering) before any streaming
152
158
  # response headers are mutated.
153
159
  def emit_ruact_response(pipeline, html, render_context, streaming:)
160
+ # Story 15.5 (FR109) — record which page shape ruact rendered so the
161
+ # `Ruact::Server` dev log (`__ruact_log_response_shape!`) can name it and,
162
+ # crucially, stay SILENT on a plain Rails render (this flag is never set
163
+ # there). `@__`-prefixed → never leaks into `view_assigns`.
164
+ @__ruact_negotiated_page = ruact_request? ? :flight : :html_shell
165
+
154
166
  if ruact_request? && streaming
155
167
  enumerator = pipeline.render({ html: html, render_context: render_context }, mode: :stream)
156
168
  response.headers["Content-Type"] = "text/x-component; charset=utf-8"
@@ -203,6 +215,9 @@ module Ruact
203
215
  # Flight redirect and re-render as an `errors` prop (no-op when untouched).
204
216
  __ruact_stash_errors_in_flash
205
217
 
218
+ # Story 15.5 (FR109) — mark the Flight-redirect sub-shape for the dev log.
219
+ @__ruact_negotiated_page = :flight_redirect
220
+
206
221
  render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
207
222
  content_type: "text/x-component"
208
223
  end
data/lib/ruact/doctor.rb CHANGED
@@ -6,7 +6,7 @@ require "pathname"
6
6
  module Ruact
7
7
  # Runs a suite of installation health checks and prints ✓/✗ per check.
8
8
  # Extracted from the ruact:doctor Rake task for direct testability (FR27).
9
- class Doctor
9
+ class Doctor # rubocop:disable Metrics/ClassLength
10
10
  CHECKS = %i[manifest vite controller layout streaming legacy_constant serialize_only flight_middleware].freeze
11
11
  # Built via Array#join so the gem-CI `name-propagation` guard does not
12
12
  # match these literals against itself (Story 5.1 review F4 — the doctor
@@ -64,6 +64,14 @@ module Ruact
64
64
  # future status) is treated as a failure (review finding R1).
65
65
  SUCCESS_STATUSES = %i[pass warn].freeze
66
66
 
67
+ # Story 15.3 (FR107) — version of the machine-readable `ruact:doctor --json`
68
+ # document (see {#as_json}). This is an **EXPERIMENTAL / UNSTABLE** contract:
69
+ # `0` signals the shape may change without a major version bump while the
70
+ # agent-facing introspection surface is iterated. Gate any parser on it.
71
+ # Distinct from {Ruact::ServerFunctions::Snapshot::VERSION_V2} (the internal
72
+ # codegen bridge version) — do not conflate the two.
73
+ SCHEMA_VERSION = 0
74
+
67
75
  # @param serialize_only_root [String] directory whose `**/*.rb` is scanned
68
76
  # for the serialize-only tripwire. Defaults to the gem's own `lib/`;
69
77
  # injectable so specs can point it at a fixture tree.
@@ -78,16 +86,61 @@ module Ruact
78
86
 
79
87
  def run
80
88
  puts "[ruact] Health check"
81
- results = CHECKS.map { |check| send(:"check_#{check}") }
82
- results.each { |status, message| puts format_result(status, message) }
89
+ computed = results
90
+ computed.each { |status, message| puts format_result(status, message) }
83
91
  # A :warn must NOT fail the run (Story 13.1 AC3); only :pass / :warn are
84
92
  # success. An unexpected status (rendered `✗`) fails loudly rather than
85
93
  # being silently treated as a pass (review finding R1).
86
- passed = results.all? { |status, _| SUCCESS_STATUSES.include?(status) }
94
+ passed = passed?(computed)
87
95
  puts "Run rails generate ruact:install to fix configuration issues" unless passed
88
96
  passed
89
97
  end
90
98
 
99
+ # Runs every check ONCE and returns the raw result tuples, index-aligned with
100
+ # {CHECKS}. Each tuple is `[status, message]` or `[status, message,
101
+ # remediation]` (the optional 3rd element is a machine-readable fix string,
102
+ # nil when the check has no cleanly separable remediation). Shared by {#run}
103
+ # (human path) and {#as_json} (JSON path) so a check like `check_vite` — which
104
+ # opens a socket — never runs twice. (Story 15.3)
105
+ #
106
+ # @return [Array<Array>] one tuple per check, in {CHECKS} order.
107
+ def results
108
+ CHECKS.map { |check| send(:"check_#{check}") }
109
+ end
110
+
111
+ # @param computed [Array<Array>] result tuples (defaults to a fresh {#results} run).
112
+ # @return [Boolean] true when NO check failed — only `:pass`/`:warn` are
113
+ # success (mirrors {#run}'s rule; an unexpected status fails).
114
+ def passed?(computed = results)
115
+ computed.all? { |status, _| SUCCESS_STATUSES.include?(status) }
116
+ end
117
+
118
+ # Story 15.3 (FR107) — the machine-readable `ruact:doctor --json` document.
119
+ # Reuses the SAME {#results} tuples the human path prints (no double-run), so
120
+ # the JSON never disagrees with the `✓/⚠/✗` output. EXPERIMENTAL shape — gate
121
+ # parsers on `schema_version` (see {SCHEMA_VERSION}). Emits NO prose — the
122
+ # rake task prints ONLY this document in JSON mode.
123
+ #
124
+ # @return [Hash] `{ "schema_version" => Integer, "status" =>
125
+ # "pass"|"fail", "checks" => [{ "name" =>, "status" =>, "message" =>,
126
+ # "remediation" => (String|nil) }] }`.
127
+ def as_json
128
+ computed = results
129
+ {
130
+ "schema_version" => SCHEMA_VERSION,
131
+ "status" => passed?(computed) ? "pass" : "fail",
132
+ "checks" => CHECKS.each_index.map do |i|
133
+ status, message, remediation = computed[i]
134
+ {
135
+ "name" => CHECKS[i].to_s,
136
+ "status" => status.to_s,
137
+ "message" => message,
138
+ "remediation" => remediation
139
+ }
140
+ end
141
+ }
142
+ end
143
+
91
144
  private
92
145
 
93
146
  def check_manifest
@@ -95,7 +148,8 @@ module Ruact
95
148
  if Pathname(path).exist?
96
149
  [:pass, "Manifest found at #{path}"]
97
150
  else
98
- [:fail, "Manifest not found — run vite build"]
151
+ [:fail, "Manifest not found — run vite build",
152
+ "Run vite build (or bin/dev) to generate the client manifest."]
99
153
  end
100
154
  end
101
155
 
@@ -103,7 +157,8 @@ module Ruact
103
157
  TCPSocket.new("localhost", 5173).close
104
158
  [:pass, "Vite accessible at localhost:5173"]
105
159
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
106
- [:fail, "Vite not accessible at localhost:5173 — run npm run dev"]
160
+ [:fail, "Vite not accessible at localhost:5173 — run npm run dev",
161
+ "Run npm run dev (or bin/dev) to start the Vite dev server."]
107
162
  end
108
163
 
109
164
  def check_controller
@@ -111,7 +166,8 @@ module Ruact
111
166
  if File.exist?(path) && File.read(path).include?("Ruact::Controller")
112
167
  [:pass, "Ruact::Controller included in ApplicationController"]
113
168
  else
114
- [:fail, "Ruact::Controller not included in ApplicationController"]
169
+ [:fail, "Ruact::Controller not included in ApplicationController",
170
+ "Run rails generate ruact:install to include Ruact::Controller in ApplicationController."]
115
171
  end
116
172
  end
117
173
 
@@ -120,7 +176,8 @@ module Ruact
120
176
  if File.exist?(path) && File.read(path).include?("ruact: root")
121
177
  [:pass, "React shell present in application.html.erb"]
122
178
  else
123
- [:fail, "React shell missing from application.html.erb"]
179
+ [:fail, "React shell missing from application.html.erb",
180
+ "Run rails generate ruact:install to add the React shell to application.html.erb."]
124
181
  end
125
182
  end
126
183
 
@@ -198,7 +255,8 @@ module Ruact
198
255
  [:warn,
199
256
  "#{present.join(', ')} is mounted and may transform `text/x-component` (Flight) responses, " \
200
257
  "breaking the wire contract / streaming. Exclude Flight responses from compression " \
201
- "(don't compress `text/x-component`) or mount it so it does not wrap the Flight routes."]
258
+ "(don't compress `text/x-component`) or mount it so it does not wrap the Flight routes.",
259
+ "Exclude text/x-component from compression, or mount the middleware so it does not wrap Flight routes."]
202
260
  end
203
261
 
204
262
  # Returns the app middleware stack to scan, or nil when unavailable (no