ruact 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) 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 +67 -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 +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Generators
5
+ class ScaffoldGenerator < Rails::Generators::NamedBase
6
+ # Story 10.3 — the shadcn Form's view-model helpers: which `@/components/ui/*`
7
+ # primitives to import, the component's prop signature, and the `references`
8
+ # → `<Select>` options sourcing (controller ivar → prop). Extracted into its
9
+ # own module to keep {ScaffoldGenerator} within its class-length budget
10
+ # (mirrors the 10.2 {ScaffoldAttribute} extraction). Mixed in below the
11
+ # `no_tasks` boundary so these never register as Thor commands.
12
+ module FormHelpers
13
+ # The :input-family shadcn controls (all render an `<Input>`, varying only
14
+ # by `type=`); extracted so the predicate's array literal isn't rebuilt on
15
+ # every loop iteration.
16
+ INPUT_SHADCN_CONTROLS = %i[input number date datetime].freeze
17
+
18
+ # The `references` attributes — each renders a shadcn `<Select>` whose
19
+ # options the controller loads (ivar → prop). Empty for a reference-free
20
+ # model (the whole options path then stays inert).
21
+ def reference_attributes
22
+ scaffold_attributes.select(&:reference?)
23
+ end
24
+
25
+ # The eager-options cap (template-friendly accessor for the constant).
26
+ def reference_options_limit
27
+ ScaffoldGenerator::REFERENCE_OPTIONS_LIMIT
28
+ end
29
+
30
+ # Story 10.3 (AC6) — the controller expression loading a `references`
31
+ # field's options as plain `{ "id" =>, "label" => }` row hashes (no
32
+ # `as_json`; the view passes them straight through as a prop). Labels fall
33
+ # back name → title → `to_s`. Capped at the threshold + 1 so the eager
34
+ # `<Select>` never balloons (a > threshold parent set wants the documented
35
+ # server-search combobox instead).
36
+ def reference_options_expr(ref)
37
+ label = "record.try(:name) || record.try(:title) || record.to_s"
38
+ "#{ref.reference_class_name}.limit(#{reference_options_limit + 1})" \
39
+ ".map { |record| { \"id\" => record.id, \"label\" => #{label} } }"
40
+ end
41
+
42
+ # --- shadcn Form import predicates ------------------------------------
43
+ # Emit only the `@/components/ui/*` imports the model's controls use, so a
44
+ # reference-free Form never imports `Select`, a textarea-free Form never
45
+ # imports `Textarea`, etc.
46
+
47
+ def form_uses_input?
48
+ scaffold_attributes.any? { |attr| INPUT_SHADCN_CONTROLS.include?(attr.shadcn_control) }
49
+ end
50
+
51
+ def form_uses_textarea?
52
+ scaffold_attributes.any? { |attr| attr.shadcn_control == :textarea }
53
+ end
54
+
55
+ def form_uses_switch?
56
+ scaffold_attributes.any? { |attr| attr.shadcn_control == :switch }
57
+ end
58
+
59
+ def form_uses_select?
60
+ reference_attributes.any?
61
+ end
62
+
63
+ # The Form component's destructured props — `initial` plus one options
64
+ # prop per `references` field (`authorOptions = []`).
65
+ def form_props_params
66
+ ["initial = null"] + reference_attributes.map { |ref| "#{ref.options_prop} = []" }
67
+ end
68
+
69
+ # FR99 — the Form component's prop type (typed mode only).
70
+ def form_props_type
71
+ parts = ["initial?: #{class_name}Row | null"]
72
+ parts += reference_attributes.map { |ref| "#{ref.options_prop}?: { id: number; label: string }[]" }
73
+ "{ #{parts.join('; ')} }"
74
+ end
75
+
76
+ # FR100 — the `__ruactContract` props object. EVERY prop the view may pass
77
+ # must be declared or the 13.5 call-site validator rejects it: `initial`
78
+ # plus each `references` options prop the new/edit views hand the Form.
79
+ # All optional (`new` omits `initial`; a reference-free Form omits options).
80
+ def form_contract_props
81
+ parts = ['initial: "optional"']
82
+ parts += reference_attributes.map { |ref| %(#{ref.options_prop}: "optional") }
83
+ "{ #{parts.join(', ')} }"
84
+ end
85
+
86
+ # The `<Input type>` for the :input-family controls (string→text,
87
+ # number, date, datetime→datetime-local). The shadcn `<Input>` forwards
88
+ # `type` to the native element, so the wire formats match for free.
89
+ def html_input_type(attr)
90
+ case attr.shadcn_control
91
+ when :number then "number"
92
+ when :date then "date"
93
+ when :datetime then "datetime-local"
94
+ else "text"
95
+ end
96
+ end
97
+
98
+ # Story 14.4 (FR103) — the native `<input type>` for the agnostic Form's
99
+ # input-family controls, mapped off the agnostic `ScaffoldAttribute#control`
100
+ # (TYPE_MAP) rather than the shadcn `shadcn_control`. boolean (`:checkbox`)
101
+ # and `references` (a `<select>`) are dispatched separately in the agnostic
102
+ # template, so this only resolves the text/number/date/datetime inputs.
103
+ def native_input_type(attr)
104
+ case attr.control
105
+ when :number then "number"
106
+ when :date then "date"
107
+ when :datetime then "datetime-local"
108
+ else "text"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,491 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "rails/generators"
6
+ require "rails/generators/named_base"
7
+ require "ruact"
8
+ require_relative "scaffold_attribute"
9
+ require_relative "scaffold_form_helpers"
10
+ require_relative "scaffold_shadcn_preflight"
11
+
12
+ module Ruact
13
+ module Generators
14
+ # Generates a complete CRUD skeleton on the v2 route-driven contract:
15
+ #
16
+ # rails generate ruact:scaffold Post title:string body:text published:boolean
17
+ #
18
+ # Story 14.3 (FR102) — the scaffold is now self-contained with Rails parity:
19
+ # it first DELEGATES to Rails' own public `resource` generator (model +
20
+ # migration + the `resources` route + host-framework test stubs, honoring
21
+ # `config.generators test_framework`), then OVERLAYS the ruact layer on top.
22
+ # No manual `rails generate model …` step is required anymore.
23
+ #
24
+ # Produces (Story 10.1 — the *skeleton*; Stories 10.2/10.3/10.4 upgrade the
25
+ # components to shadcn DataTable/Form/AlertDialog):
26
+ #
27
+ # 0. app/models/post.rb + db/migrate/*_create_posts.rb + host-framework
28
+ # model/controller test stubs — generated by the delegated Rails
29
+ # `resource` generator (14.3), which also draws `resources :posts`.
30
+ # 1. app/controllers/posts_controller.rb — seven RESTful actions, the v2
31
+ # shape (`include Ruact::Server`, implicit `default_render` on GET,
32
+ # Bucket-2 JSON on non-GET, server-driven `redirect_to`, FR98
33
+ # `ruact_errors`, raw `:id` finders with commented FR96 opt-in). The
34
+ # overlay `force`-overwrites `resource`'s bare controller (14.3 D2).
35
+ # 2. config/routes.rb — `resources :posts` injected.
36
+ # 3. app/javascript/components/PostList.tsx, PostForm.tsx,
37
+ # PostDeleteDialog.tsx — plain working React components, typed against
38
+ # the server boundary (FR99) and carrying an opt-in `__ruactContract`
39
+ # (FR100). `--javascript` emits untyped `.jsx` instead.
40
+ # 4. app/views/posts/{index,show,new,edit}.html.erb — render the components
41
+ # with props from controller-set ivars (no `as_json` in the view).
42
+ # 5. spec/requests/posts_spec.rb — a light controller smoke spec
43
+ # (RSpec-only; emitted only when the host test framework is RSpec — 14.3
44
+ # D4 — `force`-overwriting `resource`'s same-path RSpec request stub).
45
+ #
46
+ # Prerequisite: `rails generate ruact:install` must have run first (it creates
47
+ # `app/javascript/.ruact/` and primes the route-driven codegen that emits the
48
+ # `createPost`/`updatePost`/`destroyPost` accessors the components import).
49
+ # rubocop:disable Metrics/ClassLength
50
+ # Thor requires command methods (the public tasks) and `class_option`
51
+ # declarations to live on the generator class itself; every non-command
52
+ # helper is already extracted into a module ({FormHelpers},
53
+ # {ShadcnPreflight}) or class ({ScaffoldAttribute}). The remaining surface
54
+ # is irreducible Thor command/option declarations + their view-model
55
+ # accessors, so the class sits a little over the default length budget.
56
+ class ScaffoldGenerator < Rails::Generators::NamedBase
57
+ source_root File.expand_path("templates", __dir__)
58
+
59
+ argument :attributes, type: :array, default: [],
60
+ banner: "field:type field:type"
61
+
62
+ class_option :javascript, type: :boolean, default: false,
63
+ desc: "Emit untyped .jsx components instead of typed .tsx (forfeits FR99/FR100)"
64
+
65
+ # Story 14.5 (FR103b) — the explicit opt-in to the Epic 10 shadcn/ui design
66
+ # system. Without it the scaffold emits design-system-agnostic components
67
+ # (Story 14.4 default); with it `create_components` renders the byte-preserved
68
+ # shadcn DataTable/Form/AlertDialog templates AND the dependency pre-flight
69
+ # ({#check_shadcn_setup}) runs. The pre-flight + `--skip-shadcn-check` are
70
+ # meaningful ONLY under this flag (the default path never runs them).
71
+ class_option :shadcn, type: :boolean, default: false,
72
+ desc: "Opt in to the shadcn/ui design system: emit the Epic 10 " \
73
+ "DataTable/Form/AlertDialog components and run the shadcn dependency pre-flight"
74
+
75
+ class_option :skip_shadcn_check, type: :boolean, default: false,
76
+ desc: "Under --shadcn, skip the shadcn/ui dependency pre-flight and write " \
77
+ "anyway (emits an in-file warning banner when @/components/ui/* is " \
78
+ "missing). No effect on the default (agnostic) path."
79
+
80
+ desc "Generates a complete CRUD skeleton (controller, route, ERB views, React components) on the v2 contract"
81
+
82
+ # Story 10.3 — the shadcn Form view-model helpers (import predicates, prop
83
+ # signature, `references` options). Module-housed so they don't register as
84
+ # Thor commands and to keep this class within its length budget.
85
+ include FormHelpers
86
+
87
+ # Story 10.5 — the shadcn dependency pre-flight's read-only helpers
88
+ # (detection / guidance messages / version read). Module-housed so they
89
+ # don't register as Thor commands; only `check_shadcn_setup` below is a
90
+ # command. See {ShadcnPreflight}.
91
+ include ShadcnPreflight
92
+
93
+ # Supported attribute types → their TS wire type (FR99 wire-union grain:
94
+ # string | number | boolean | null) + the plain form control kind. The rich
95
+ # shadcn control mapping lands in 10.2/10.3; this stays minimal.
96
+ TYPE_MAP = {
97
+ "string" => { ts: "string", control: :text },
98
+ "text" => { ts: "string", control: :textarea },
99
+ "integer" => { ts: "number", control: :number },
100
+ "float" => { ts: "number", control: :number },
101
+ "decimal" => { ts: "number", control: :number },
102
+ "boolean" => { ts: "boolean", control: :checkbox },
103
+ "date" => { ts: "string", control: :date },
104
+ "datetime" => { ts: "string", control: :datetime },
105
+ "references" => { ts: "number", control: :number }
106
+ }.freeze
107
+
108
+ SUPPORTED_TYPES = TYPE_MAP.keys.freeze
109
+
110
+ # Column types the `search` query's case-insensitive LIKE scope spans —
111
+ # matching numeric/date/boolean columns by substring is meaningless.
112
+ SEARCHABLE_COLUMN_TYPES = %w[string text].freeze
113
+
114
+ # Story 10.3 (AC6) — parent-set size at/under which a `references` field
115
+ # renders an eager shadcn `<Select>` of controller-provided options. Above
116
+ # it, a server-search combobox is the right control; the generated Form
117
+ # leaves a documented opt-in trail (the parent-options query it would query
118
+ # is out of this scaffold's scope — the parent model isn't scaffolded here).
119
+ # Adjustable: re-run with a different value and re-generate, or edit the
120
+ # emitted controller's `.limit(...)`.
121
+ REFERENCE_OPTIONS_LIMIT = 100
122
+
123
+ # Documentation anchor referenced by the unknown-type error message (AC4).
124
+ DOCS_POINTER = "https://github.com/luizcg/ruact/blob/main/website/docs/api/scaffold.md#attribute-types"
125
+
126
+ # Story 10.5 (AC1, AC2, AC4) — the shadcn/ui dependency PRE-FLIGHT: detect
127
+ # the host's shadcn state (complete / missing / partial) and either proceed
128
+ # (complete), or print copy-pasteable `npx shadcn` guidance and ABORT
129
+ # before any write (missing / partial), unless `--skip-shadcn-check`
130
+ # bypasses the abort. The generator NEVER auto-runs `npx`/`npm` (AC4 — no
131
+ # surprising network/install behavior, critical for CI). Aborting via
132
+ # `raise Thor::Error` mirrors `assert_supported_attribute_types!`.
133
+ #
134
+ # Story 14.5 (FR103b, D1) — RE-PROMOTED to a Thor command (Story 14.4 had
135
+ # de-registered it via `remove_command` so the default path never aborted).
136
+ # It is now `--shadcn`-GATED: the `return unless shadcn?` guard makes it a
137
+ # no-op on the default (agnostic) path — a fresh app with no shadcn still
138
+ # scaffolds without a pre-flight or abort — while under `--shadcn` it runs
139
+ # the byte-preserved {ShadcnPreflight} detection / guidance / abort (zero
140
+ # partial state, `raise Thor::Error` on missing/partial unless
141
+ # `--skip-shadcn-check`). Declared FIRST among the command tasks (before
142
+ # {#generate_rails_resource}) so the `--shadcn` abort precedes every write.
143
+ def check_shadcn_setup
144
+ return unless shadcn?
145
+
146
+ run_shadcn_preflight! # {ShadcnPreflight} — detection / guidance / abort
147
+ end
148
+
149
+ # Story 14.3 (FR102, AC1) — delegate model + migration + the `resources`
150
+ # route + host-framework test stubs to Rails' own public `resource`
151
+ # generator, THEN let the overlay tasks below win where they intersect
152
+ # (the controller force-overwrites resource's bare one; the route guard
153
+ # no-ops on the already-drawn line). Declared right AFTER the `--shadcn`-gated
154
+ # pre-flight {#check_shadcn_setup} (Story 14.5) so a `--shadcn` missing-setup
155
+ # abort precedes this delegation (zero partial state); on the default path
156
+ # the pre-flight no-ops and this runs first among the writing tasks. A bad
157
+ # input still fails loud at `parse_attributes!`, before any task runs (zero
158
+ # partial state). The real
159
+ # sub-generator call lives behind the stubbable `invoke_rails_resource`
160
+ # seam (mirroring Story 14.1's `run_npm_install`) — the unit specs stub it
161
+ # (a bare `mktmpdir` is not a booted Rails app, so a real `invoke
162
+ # "resource"` cannot run there); the live end-to-end delegation is proven
163
+ # by Story 14.6's clean-room Docker E2E.
164
+ def generate_rails_resource
165
+ invoke_rails_resource(name, *raw_attribute_args)
166
+ end
167
+
168
+ # Story 14.3 (AC4, D2) — `force: true` so ruact's v2 controller silently
169
+ # overwrites the empty 7-action controller `resource` emits (no Thor
170
+ # "conflict?" prompt, no leftover bare controller). The overlay is the
171
+ # real controller; resource's controller-test stub is harmless and stands
172
+ # as the Rails-parity controller test.
173
+ def create_controller
174
+ template "controller.rb.tt", File.join("app/controllers", "#{plural_file_name}_controller.rb"),
175
+ force: true
176
+ end
177
+
178
+ # AC5 — idempotent on re-run: Rails owns the insertion, but its `route`
179
+ # action is not duplicate-aware across runs, so guard on the drawn line
180
+ # first. (A commented `collection { post :publish }` FR96 anchor is NOT
181
+ # injected here — Rails' `route` action only takes a single statement; the
182
+ # publish demo lives commented in the controller, where it reads cleanly.)
183
+ def add_resource_route
184
+ routes_file = Pathname(destination_root).join("config/routes.rb")
185
+ if routes_file.exist? && routes_file.read.match?(/^\s*resources +:#{Regexp.escape(plural_name)}\b/)
186
+ say_status "skip", "resources :#{plural_name} already routed", :yellow
187
+ return
188
+ end
189
+
190
+ route "resources :#{plural_name}"
191
+ end
192
+
193
+ # AC5 — the client-driven read path. Emits the resource query
194
+ # (`<Plural>Query < ApplicationQuery` with a `search(q:)` method) in BOTH
195
+ # `.tsx` and `.jsx` modes (the query is server-side Ruby; the language flag
196
+ # only governs the React component). The `ApplicationQuery` base is created
197
+ # idempotently — `ruact:install` does NOT ship it, and a second scaffold in
198
+ # the same app must NOT clobber a customized base.
199
+ def create_queries
200
+ template "queries/query.rb.tt", File.join("app/queries", "#{plural_file_name}_query.rb")
201
+
202
+ application_query = File.join("app/queries", "application_query.rb")
203
+ return if Pathname(destination_root).join(application_query).exist?
204
+
205
+ template "queries/application_query.rb.tt", application_query
206
+ end
207
+
208
+ # AC5 — mount the resource query so its `search` method becomes the named
209
+ # GET route the codegen exports as `search` (consumed by `useQuery`).
210
+ # Idempotent on re-run: guard on the drawn `ruact_queries <Plural>Query`
211
+ # line first (sibling of {#add_resource_route}'s `resources :posts` guard).
212
+ def add_query_route
213
+ routes_file = Pathname(destination_root).join("config/routes.rb")
214
+ if routes_file.exist? && routes_file.read.match?(/^\s*ruact_queries\s+#{Regexp.escape(query_class_name)}\b/)
215
+ say_status "skip", "ruact_queries #{query_class_name} already routed", :yellow
216
+ return
217
+ end
218
+
219
+ route "ruact_queries #{query_class_name}"
220
+ end
221
+
222
+ def create_views
223
+ %w[index show new edit].each do |view|
224
+ template "views/#{view}.html.erb.tt", File.join("app/views", plural_file_name, "#{view}.html.erb")
225
+ end
226
+ end
227
+
228
+ # Story 14.4 (FR103) / Story 14.5 (FR103b) — the component templates are the
229
+ # ONLY thing the `--shadcn` opt-in flips:
230
+ # * default (no flag) → design-system-AGNOSTIC components (plain native
231
+ # HTML, Rails-default CSS, no shadcn/ui, no Tailwind) from
232
+ # `components/agnostic/` (Story 14.4).
233
+ # * `--shadcn` → the byte-preserved Epic 10 shadcn DataTable / Form /
234
+ # AlertDialog templates from `components/` (Story 14.5 — restored from
235
+ # behind the flag, NOT rewritten).
236
+ # `component_ext` (`.tsx` / `.jsx`) is selected independently so
237
+ # `--shadcn --javascript` composes (untyped `.jsx` shadcn output). Everything
238
+ # else (controller, route, query, views, smoke spec) is identical on both
239
+ # paths.
240
+ def create_components
241
+ dir = shadcn? ? "components" : "components/agnostic"
242
+ template "#{dir}/List.tsx.tt",
243
+ File.join("app/javascript/components", "#{class_name}List.#{component_ext}")
244
+ template "#{dir}/Form.tsx.tt",
245
+ File.join("app/javascript/components", "#{class_name}Form.#{component_ext}")
246
+ template "#{dir}/DeleteDialog.tsx.tt",
247
+ File.join("app/javascript/components", "#{class_name}DeleteDialog.#{component_ext}")
248
+ end
249
+
250
+ # Story 14.3 (AC2, AC4, D4) — ruact's v2-contract request spec is
251
+ # authoritative, but it is RSpec-only (`require "rails_helper"`,
252
+ # `type: :request`). Two reconciliations:
253
+ # 1. Framework gate — emit ONLY when the host test framework is RSpec, so
254
+ # a Minitest app never gets a broken `rails_helper`-requiring file
255
+ # (the delegated Rails Minitest model/controller tests stand alone).
256
+ # 2. RSpec-path collision — with `rspec-rails`, `resource` emits a request
257
+ # spec at this SAME path; `force: true` makes ruact's v2 spec win, so
258
+ # there is exactly one request spec and it is ruact's.
259
+ def create_smoke_spec
260
+ return unless rspec_smoke_spec?
261
+
262
+ template "request_spec.rb.tt", File.join("spec/requests", "#{plural_file_name}_spec.rb"),
263
+ force: true
264
+ end
265
+
266
+ no_tasks do # rubocop:disable Metrics/BlockLength
267
+ # Rails' `NamedBase#initialize` calls `parse_attributes!` to turn each raw
268
+ # `field:type` arg into a `GeneratedAttribute`. We hook it to enforce the
269
+ # TS-type allowlist FIRST (AC4), because Rails' own unknown-type handling
270
+ # is wrong for us on two counts: (1) its error neither lists ruact's
271
+ # supported types nor points at the docs, and (2) once ActiveRecord is
272
+ # loaded, `GeneratedAttribute.parse` reaches for a DB connection to
273
+ # validate an unrecognized type (raising `ConnectionNotDefined` instead of
274
+ # a usable message). Failing here — before `super`, before any task runs —
275
+ # gives the clean message AND guarantees no partial output (construction
276
+ # aborts). Raised as a `Thor::Error` so the CLI prints it without a Ruby
277
+ # backtrace and exits non-zero.
278
+ def parse_attributes!
279
+ assert_flat_resource_name!
280
+ assert_supported_attribute_types!
281
+ # Story 14.3 — capture the RAW `field:type` strings before `super`
282
+ # turns `attributes` into Rails `GeneratedAttribute`s. These verbatim
283
+ # args are what `resource` re-parses into its own attributes (correct
284
+ # migration column types, length/index modifiers, polymorphic refs),
285
+ # so the delegation does NOT reconstruct from ruact's view-models.
286
+ @raw_attribute_args = Array(attributes).map(&:to_s)
287
+ super
288
+ end
289
+
290
+ # Story 14.3 — the verbatim `field:type` args passed to the resource
291
+ # generator (captured in {#parse_attributes!} before `super` rewrote
292
+ # `attributes` into `GeneratedAttribute`s).
293
+ def raw_attribute_args
294
+ @raw_attribute_args || []
295
+ end
296
+
297
+ # Story 14.3 — the stubbable delegation seam (AC1, AC5). Isolated so the
298
+ # unit specs can assert/stub the sub-generator invocation without a
299
+ # booted Rails app (mirrors Story 14.1's `run_npm_install`). Uses only
300
+ # the PUBLIC `resource` generator surface via Thor's in-generator
301
+ # `invoke`, which shares this generator's `destination_root`/shell and
302
+ # participates in the host's `config.generators` hooks (so the host's
303
+ # `test_framework` decides Minitest-vs-RSpec stub form — AC2).
304
+ def invoke_rails_resource(resource_name, *attribute_args)
305
+ invoke "resource", [resource_name, *attribute_args]
306
+ end
307
+
308
+ # Story 14.3 (AC2, D4) — whether to emit ruact's RSpec-only v2 smoke
309
+ # spec. Honors the host's configured test framework: emit for `:rspec`;
310
+ # skip for Minitest (`:test_unit`/`:minitest`) so no broken
311
+ # `rails_helper`-requiring file lands there. When the framework can't be
312
+ # determined (e.g. no booted app — the unit harness), default to
313
+ # emitting: an RSpec request spec is the scaffold's documented smoke
314
+ # default, and a real host always resolves a concrete framework.
315
+ def rspec_smoke_spec?
316
+ case host_test_framework
317
+ when :rspec, nil then true
318
+ else false
319
+ end
320
+ end
321
+
322
+ # Best-effort read of the host app's configured generators
323
+ # `test_framework` (the same signal `resource`'s test hook reads). `nil`
324
+ # when there is no booted application or the option is unset. Defensive:
325
+ # never raises out into the generator run.
326
+ def host_test_framework
327
+ app = Rails.application
328
+ return nil unless app
329
+
330
+ app.config.generators.options.dig(:rails, :test_framework)
331
+ rescue StandardError
332
+ nil
333
+ end
334
+
335
+ # 10.1 generates a FLAT resource (`Post`). A namespaced name (`Admin::Post`
336
+ # / `admin/post`) would desync across the emitted files — the controller
337
+ # file lands at the un-namespaced `posts_controller.rb` path while the
338
+ # class text is `Admin::PostsController` (Zeitwerk-invalid), component
339
+ # filenames/imports become `Admin::PostList` / `createAdmin::Post` (not
340
+ # valid TS, and not the route codegen's `createAdminPost`). Rather than
341
+ # emit silently-broken output, fail loud: namespaced scaffolds are a
342
+ # later concern. `name`/`class_name` are already assigned by NamedBase's
343
+ # `assign_names!` (it runs before `parse_attributes!`).
344
+ def assert_flat_resource_name!
345
+ return unless name.to_s.include?("/") || class_name.include?("::")
346
+
347
+ raise Thor::Error, <<~MSG.chomp
348
+ ruact:scaffold — namespaced resources (e.g. `#{name}`) are not supported yet.
349
+ Generate a flat resource, e.g. `rails generate ruact:scaffold Post title:string`.
350
+ MSG
351
+ end
352
+
353
+ def assert_supported_attribute_types!
354
+ unknown = Array(attributes).filter_map do |arg|
355
+ name, type = arg.to_s.split(":", 2)
356
+ type = (type || "string").split("{").first
357
+ "#{name}:#{type}" unless SUPPORTED_TYPES.include?(type)
358
+ end
359
+ return if unknown.empty?
360
+
361
+ raise Thor::Error, <<~MSG.chomp
362
+ ruact:scaffold — unknown attribute type(s): #{unknown.join(', ')}
363
+ Supported types: #{SUPPORTED_TYPES.join(', ')}
364
+ See the attribute type-mapping table: #{DOCS_POINTER}
365
+ MSG
366
+ end
367
+
368
+ # Parsed attribute view-models (memoized), built from the
369
+ # `GeneratedAttribute`s Rails produced (their type allowlist was already
370
+ # enforced in {#parse_attributes!}). `references` columns address `<name>_id`.
371
+ def scaffold_attributes
372
+ @scaffold_attributes ||= attributes.map do |arg|
373
+ name, type = arg.to_s.split(":", 2)
374
+ ScaffoldAttribute.new(name, (type || "string").split("{").first)
375
+ end
376
+ end
377
+
378
+ def typescript?
379
+ !options[:javascript]
380
+ end
381
+
382
+ # Story 14.5 (FR103b) — the explicit shadcn opt-in. Gates BOTH the
383
+ # component-template selection ({#create_components}) and the dependency
384
+ # pre-flight ({#check_shadcn_setup}); false (default) → the agnostic path.
385
+ def shadcn?
386
+ options[:shadcn]
387
+ end
388
+
389
+ def component_ext
390
+ typescript? ? "tsx" : "jsx"
391
+ end
392
+
393
+ # `post` → `posts` file stem for nested paths (controller + views dir).
394
+ def plural_file_name
395
+ file_name.pluralize
396
+ end
397
+
398
+ # The Rails resource controller is PLURAL (`PostsController` in
399
+ # `posts_controller.rb`) — Zeitwerk requires the constant to match the
400
+ # pluralized path. `class_name` is the singular MODEL constant (`Post`),
401
+ # so the controller class name is its pluralization (namespace-safe:
402
+ # `Admin::Post` → `Admin::Posts`).
403
+ def controller_class_name
404
+ class_name.pluralize
405
+ end
406
+
407
+ # The read-side query class — PLURAL, mirroring the golden `PostsQuery`
408
+ # (file `posts_query.rb`) and Zeitwerk's path↔constant rule. Mounted via
409
+ # `ruact_queries <Plural>Query`; its `search` method becomes `GET /q/search`.
410
+ def query_class_name
411
+ "#{class_name.pluralize}Query"
412
+ end
413
+
414
+ # The JS import alias for the query's `search` accessor. The codegen
415
+ # exports a generic `search` (from `<Plural>Query#search`); the component
416
+ # aliases it `search<Plural>` to avoid a bare-`search` collision — exactly
417
+ # as the golden does (`search as searchPosts`).
418
+ def js_search_alias
419
+ "search#{class_name.pluralize}"
420
+ end
421
+
422
+ # The columns the search `LIKE` scope spans — string/text only (a
423
+ # case-insensitive match over numeric/date columns is meaningless).
424
+ def searchable_attributes
425
+ scaffold_attributes.select { |attr| SEARCHABLE_COLUMN_TYPES.include?(attr.type) }
426
+ end
427
+
428
+ # FR99 — the generated `type <Model>Row` body, e.g.
429
+ # "id: number; title: string | null; published: boolean | null". Attribute
430
+ # columns are `| null`: a scaffolded Rails column is nullable by default and
431
+ # the generator has no schema access to prove otherwise, so the honest type
432
+ # is the full FR99 wire union (`string | number | boolean | null`). `id` (the
433
+ # PK) is never null. The components null-guard these (`?? ""` / `Boolean(...)`).
434
+ def ts_row_fields
435
+ fields = scaffold_attributes.map { |attr| "#{attr.column_name}: #{attr.ts_type} | null" }
436
+ (["id: number"] + fields).join("; ")
437
+ end
438
+
439
+ # A Ruby hash literal serializing +var+ to plain row values (string keys),
440
+ # used by the index/edit ERB views — no `as_json` in the view. Each value
441
+ # is shaped so the wire value matches both the declared FR99 `<Model>Row`
442
+ # type AND the form input that binds it (see {#row_value_expr}).
443
+ def serialized_row(var)
444
+ pairs = [%("id" => #{var}.id)]
445
+ pairs += scaffold_attributes.map { |attr| %("#{attr.column_name}" => #{row_value_expr(var, attr)}) }
446
+ "{ #{pairs.join(', ')} }"
447
+ end
448
+
449
+ # The Ruby expression that reads +attr+ off +var+ for the serialized row,
450
+ # coerced (nil-safe) to the type its FR99 wire type + form control expect:
451
+ # - date → `&.iso8601` (`YYYY-MM-DD`, a `<input type=date>`-valid string)
452
+ # - datetime → `&.strftime("%Y-%m-%dT%H:%M")` (a `<input type=datetime-local>`-valid string;
453
+ # a raw `Time#iso8601` carries seconds + offset the control rejects)
454
+ # - decimal → `&.to_f` (BigDecimal serializes as a STRING; the row type is `number`)
455
+ # - everything else is already a wire-correct scalar.
456
+ def row_value_expr(var, attr)
457
+ base = "#{var}.#{attr.column_name}"
458
+ case attr.control
459
+ when :date then "#{base}&.iso8601"
460
+ when :datetime then %(#{base}&.strftime("%Y-%m-%dT%H:%M"))
461
+ else attr.type == "decimal" ? "#{base}&.to_f" : base
462
+ end
463
+ end
464
+
465
+ # Story 10.4 (AC1) — the record attribute whose value titles the delete
466
+ # confirmation ("Delete '<title>'?"). No universal "title" column exists,
467
+ # so pick the first string-ish display attribute deterministically: the
468
+ # first `string` column, else the first `text`, else the PK `id` (always
469
+ # present, never null). Returns the wire `column_name`.
470
+ def display_attribute
471
+ attr = scaffold_attributes.find { |a| a.type == "string" } ||
472
+ scaffold_attributes.find { |a| a.type == "text" }
473
+ attr ? attr.column_name : "id"
474
+ end
475
+
476
+ # A valid example value (as a Ruby source literal) for the smoke spec.
477
+ def sample_value(attr)
478
+ case attr.type
479
+ when "integer", "references" then "1"
480
+ when "float", "decimal" then "1.5"
481
+ when "boolean" then "true"
482
+ when "date" then '"2026-01-01"'
483
+ when "datetime" then '"2026-01-01T00:00:00"'
484
+ else %("Sample #{attr.name}")
485
+ end
486
+ end
487
+ end
488
+ end
489
+ # rubocop:enable Metrics/ClassLength
490
+ end
491
+ end