jsx_rosetta 0.5.1 → 0.6.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +17 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
- data/lib/jsx_rosetta/backend/view_component.rb +48 -2
- data/lib/jsx_rosetta/cli.rb +175 -37
- data/lib/jsx_rosetta/icons/lucide.json +37 -0
- data/lib/jsx_rosetta/icons.rb +44 -0
- data/lib/jsx_rosetta/ir/lowering.rb +720 -31
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +187 -3
- data/lib/jsx_rosetta/ir.rb +5 -4
- data/lib/jsx_rosetta/pages_routing.rb +640 -0
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +8 -6
- data/plans/nextjs_pages_to_rails.md +200 -0
- data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
- data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
- data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
- data/plans/translator_widening_and_pages_followups.md +120 -0
- data/plans/translator_widening_slice_a.md +208 -0
- data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
- data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
- data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
- data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
- data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
- data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
- metadata +29 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Slice 4 — Next.js page-router extensions (umbrella: `translator_widening_and_pages_followups.md`)
|
|
2
|
+
|
|
3
|
+
Five extensions to the page-router story shipped in slices 1–3. Each item touches either `pages_routing.rb` (route classification) or the Phlex backend's `--rails-routes` plumbing (view placement), and several touch both. Land as one slice so the routes.rb, the view tree, and any controller stubs stay in sync.
|
|
4
|
+
|
|
5
|
+
Items from the umbrella plan, in landing order:
|
|
6
|
+
|
|
7
|
+
- **B5** — Route groups `(group)/`. Next.js 13+ convention where paren-wrapped dir segments are invisible to the URL but group files semantically. Skip the segment from URL building; carry through as namespace hint. *Lands first — pure Scanner change, no IR / backend coupling.*
|
|
8
|
+
- **B3** — Namespace nesting for multi-segment dir trees. `pages/admin/users/[id].tsx` → `Admin::UsersController#show` at `/admin/users/:id` (today: `admin#users_show`). **Documented breaking change.**
|
|
9
|
+
- **B4** — Error pages (`_error.tsx` / `404.tsx` / `500.tsx`) → `Views::Errors::<Status>` at `app/views/errors/<status>.rb`. Adds a `config.exceptions_app` comment block at the top of routes.rb.
|
|
10
|
+
- **B2** — `_app.tsx` → `app/views/layouts/application.rb` with class `Views::Layouts::Application < Views::Base`. `_document.tsx` remains skipped (HTML scaffolding is Rails's job).
|
|
11
|
+
- **B1** — `getServerSideProps` / `getStaticProps` capture into new `IR::Component#server_data_source` field. Phlex backend emits the body as a TODO comment block at the top of the rails-view file. The one item in slice 4 where AST/IR meaningfully helps.
|
|
12
|
+
|
|
13
|
+
## Dependencies
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
B5 ──┐
|
|
17
|
+
├─→ B3 ─→ B4 ─→ B2 ─→ B1
|
|
18
|
+
B3 ──┘
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
B5 and B3 both reshape `controller_for` / `route_name` in `PagesRouting::Naming`; landing B5 first means B3 doesn't have to re-handle paren-wrapped segments. B4 / B2 / B1 are independent and could ship in any order — sequencing them last keeps the routes.rb regression in one place.
|
|
22
|
+
|
|
23
|
+
## B5 — Route groups `(group)/`
|
|
24
|
+
|
|
25
|
+
### Detection
|
|
26
|
+
|
|
27
|
+
`Scanner.segment_to_path_part` already classifies bracketed segments. Add a `(group)` form: `\A\(([^)]+)\)\z` → `[:route_group, name]`. Two rules follow:
|
|
28
|
+
|
|
29
|
+
1. `rails_path_for` skips `:route_group` entries entirely (no URL segment).
|
|
30
|
+
2. `controller_for` treats `(group)` dirs as transparent — picks the first *non-group, non-bracket* segment. The group name is collected separately into a new `namespace` array on `Route`.
|
|
31
|
+
|
|
32
|
+
### Route shape
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Route = Data.define(:rails_path, :controller, :action, :source_path, :namespace)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`namespace` is `[]` for everything not under a group. Under `pages/(marketing)/about.tsx` it's `["marketing"]`. Under `pages/(marketing)/(public)/about.tsx` it's `["marketing", "public"]` (rare but valid).
|
|
39
|
+
|
|
40
|
+
### Naming
|
|
41
|
+
|
|
42
|
+
`Naming.route_name` prefixes the namespace: `["marketing"] + about` → `marketing_about_path`. URL still `/about`.
|
|
43
|
+
|
|
44
|
+
### Emitter
|
|
45
|
+
|
|
46
|
+
routes.rb wraps grouped routes in `namespace :marketing, module: "marketing", path: ""` blocks so Rails picks up the controller path (`Marketing::AboutController`) without affecting the URL. Comment above the block names the source group.
|
|
47
|
+
|
|
48
|
+
### Files
|
|
49
|
+
|
|
50
|
+
| Path | Status | Scope |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `lib/jsx_rosetta/pages_routing.rb` | modify | `Route` gets `namespace:`. Scanner's `segment_to_path_part` + `controller_for` + `rails_path_for` handle the new form. Emitter renders nested `namespace … path: ""` blocks for grouped routes. Naming prefixes route-name helper. |
|
|
53
|
+
|
|
54
|
+
### Specs
|
|
55
|
+
|
|
56
|
+
- `pages/(marketing)/about.tsx` → `get "/about"` with `as: :marketing_about` inside `namespace :marketing, path: ""`.
|
|
57
|
+
- `pages/(marketing)/index.tsx` → `get "/"` with `as: :marketing_index`.
|
|
58
|
+
- `pages/(marketing)/(public)/about.tsx` → nested namespaces.
|
|
59
|
+
- `pages/(group)/users/[id].tsx` → group + bracket dir → `Group::UsersController#show` at `/users/:id`.
|
|
60
|
+
|
|
61
|
+
## B3 — Namespace nesting for nested dirs
|
|
62
|
+
|
|
63
|
+
### Trigger
|
|
64
|
+
|
|
65
|
+
In `controller_for(dir_segments)`, current behavior: first non-bracket segment wins. New rule: when **more than one** non-bracket segment precedes the leaf, treat all but the last as namespaces and the last as the controller.
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
|
|
69
|
+
| Source path | Before | After |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `users/[id].tsx` | controller=`users`, action=`show` | unchanged |
|
|
72
|
+
| `admin/users/[id].tsx` | controller=`admin`, action=`users_show` | namespace=`["admin"]`, controller=`users`, action=`show` |
|
|
73
|
+
| `admin/users/[id]/edit.tsx` | controller=`admin`, action=`users_edit` | namespace=`["admin"]`, controller=`users`, action=`edit` |
|
|
74
|
+
| `admin/billing/invoices/index.tsx` | controller=`admin`, action=`billing_invoices_index` | namespace=`["admin", "billing"]`, controller=`invoices`, action=`index` |
|
|
75
|
+
|
|
76
|
+
Bracket dirs *between* the controller and the leaf still flow into the URL as params; they don't break namespacing. `policies/[providerSlug]/[policyId]/edit.tsx` stays controller=`policies` because there's only one non-bracket dir.
|
|
77
|
+
|
|
78
|
+
### Naming & emission
|
|
79
|
+
|
|
80
|
+
`Naming.route_name` prepends namespace segments to the existing rule. `Admin::UsersController#show` → `as: :admin_user`. Emitter wraps the controller's route block in `namespace :admin do ... end`.
|
|
81
|
+
|
|
82
|
+
### Behavioral interaction with B5
|
|
83
|
+
|
|
84
|
+
B5's namespace (from `(group)`) and B3's namespace (from nested dirs) collapse into the same `namespace` array on Route, in source order. A grouped *and* nested path like `pages/(marketing)/admin/users/index.tsx` produces `namespace = ["marketing", "admin"]`, controller=`users`. Rendered as two nested `namespace` blocks in routes.rb.
|
|
85
|
+
|
|
86
|
+
### Breaking-change notice
|
|
87
|
+
|
|
88
|
+
Existing route-name helpers change for any pages tree with multi-segment dirs:
|
|
89
|
+
|
|
90
|
+
- `admin_users_index_path` → `admin_users_path`
|
|
91
|
+
- `admin_users_show_path` → `admin_user_path(id)`
|
|
92
|
+
- `admin_billing_invoices_index_path` → `admin_billing_invoices_path`
|
|
93
|
+
|
|
94
|
+
Document in the slice-4 plan and in the CHANGELOG when shipped. Parallel to slice 3's `as:` addition: intentional rename, surfaced clearly.
|
|
95
|
+
|
|
96
|
+
### Files
|
|
97
|
+
|
|
98
|
+
| Path | Status | Scope |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `lib/jsx_rosetta/pages_routing.rb` | modify | `controller_for` returns `[controller, namespace_array]`. `Naming.route_name` & `url_helper_name` accept namespace. `Emitter.grouped_body` wraps in nested `namespace :foo do` blocks. `HrefRewriter` ignores namespace for path-matching — the rails_path string already contains everything URL-relevant. |
|
|
101
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | `rails_view_class_name` prepends namespace: `Views::Admin::Users::Show`. `ruby_path` prepends: `admin/users/show.rb`. |
|
|
102
|
+
| `lib/jsx_rosetta/cli.rb` | unchanged | The `--rails-routes` flow already plumbs the Route through; the namespace travels along for free. |
|
|
103
|
+
|
|
104
|
+
### Specs
|
|
105
|
+
|
|
106
|
+
- Two-segment nesting (`admin/users/[id].tsx`) → emits `namespace :admin do; resources :users, only: [:show]; end`-equivalent flat block.
|
|
107
|
+
- Three-segment nesting → two nested namespace blocks.
|
|
108
|
+
- Bracket dir between named segments doesn't break detection.
|
|
109
|
+
- HrefRewriter: literal `"/admin/users/123"` matches namespaced route, returns `admin_user_path(123)`.
|
|
110
|
+
|
|
111
|
+
## B4 — Error pages
|
|
112
|
+
|
|
113
|
+
Stop skipping `_error.tsx` / `404.tsx` / `500.tsx`. Emit as:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
Route(
|
|
117
|
+
rails_path: "/404", # informational; not actually used as a Rails route
|
|
118
|
+
controller: "errors",
|
|
119
|
+
action: "not_found", # or "internal_server_error" / "fallback"
|
|
120
|
+
source_path: "404.tsx",
|
|
121
|
+
namespace: [],
|
|
122
|
+
kind: :error_page
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Add a `:kind` field on `Route` (default `:standard`). Routes with `kind: :error_page` are NOT emitted as `get` lines in routes.rb — instead they get a comment block at the top explaining `config.exceptions_app = routes` wiring:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# Error pages — wire in config/application.rb:
|
|
130
|
+
# config.exceptions_app = self.routes
|
|
131
|
+
# Then declare these as ordinary routes that resolve to ErrorsController:
|
|
132
|
+
# match "/404", to: "errors#not_found", via: :all
|
|
133
|
+
# match "/500", to: "errors#internal_server_error", via: :all
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
View placement still applies — `_rails_view_route` for an error page points at `app/views/errors/<action>.rb` with class `Views::Errors::<Action>`.
|
|
137
|
+
|
|
138
|
+
Action name mapping:
|
|
139
|
+
|
|
140
|
+
| Source | Action |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `404.tsx` | `not_found` |
|
|
143
|
+
| `500.tsx` | `internal_server_error` |
|
|
144
|
+
| `_error.tsx` | `fallback` |
|
|
145
|
+
|
|
146
|
+
### Files
|
|
147
|
+
|
|
148
|
+
| Path | Status | Scope |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `lib/jsx_rosetta/pages_routing.rb` | modify | `Route` gets `kind:`. `Scanner.build_route` recognizes error leaves; `SKIPPED_LEAVES` loses the error entries. `Emitter` emits the wiring comment block at the top of routes.rb when any error-page route is present; skips them from the `get` list. |
|
|
151
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | unchanged | `rails_view_class_name` already does `Views::<Controller>::<Action>` — `errors/not_found.rb` falls out naturally. |
|
|
152
|
+
|
|
153
|
+
### Specs
|
|
154
|
+
|
|
155
|
+
- `404.tsx` → Route(`controller: "errors"`, `action: "not_found"`, `kind: :error_page`).
|
|
156
|
+
- Emitter inserts the wiring comment block when error pages are present.
|
|
157
|
+
- View placement: translating `404.tsx` with `--rails-routes` lands at `errors/not_found.rb` with class `Views::Errors::NotFound < Views::Base`.
|
|
158
|
+
|
|
159
|
+
## B2 — `_app.tsx` → application layout
|
|
160
|
+
|
|
161
|
+
Stop skipping `_app.tsx`. Emit as:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
Route(
|
|
165
|
+
rails_path: nil,
|
|
166
|
+
controller: "layouts",
|
|
167
|
+
action: "application",
|
|
168
|
+
source_path: "_app.tsx",
|
|
169
|
+
namespace: [],
|
|
170
|
+
kind: :layout
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`_document.tsx` stays in `SKIPPED_LEAVES` — Next.js's `_document` exists to override the HTML scaffolding (lang attribute, custom head/body wrappers), and Rails owns that via `app/views/layouts/application.html.erb`. We don't try to translate.
|
|
175
|
+
|
|
176
|
+
### View placement
|
|
177
|
+
|
|
178
|
+
The Phlex backend translates layouts to `app/views/layouts/application.rb` with class `Views::Layouts::Application < Views::Base`. Body is the body of the `_app.tsx` component — Next.js's `_app` typically returns `<Component {...pageProps} />` wrapped in providers, so the translation lands as:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
# Views::Layouts::Application — generated by jsx_rosetta from _app.tsx
|
|
182
|
+
class Views::Layouts::Application < Views::Base
|
|
183
|
+
def view_template
|
|
184
|
+
# TODO: Next.js providers detected — port to Rails initializers / Stimulus:
|
|
185
|
+
# <ThemeProvider> ... <Component {...pageProps} /> ... </ThemeProvider>
|
|
186
|
+
yield if block_given?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The `<Component {...pageProps} />` invocation in the source lowers to a special marker that the backend emits as `yield if block_given?`. Provider wrappers around it stay as TODO comments — wrapping behavior usually doesn't translate verbatim to Rails.
|
|
192
|
+
|
|
193
|
+
Detection rule: in lowering, when `mode == :layout` (a new mode), the lowering pass recognizes `<Component {...pageProps} />` (camelCase `Component` referencing the page-props param) and replaces it with an `IR::LayoutYield` node. The backend emits `yield if block_given?`.
|
|
194
|
+
|
|
195
|
+
### Files
|
|
196
|
+
|
|
197
|
+
| Path | Status | Scope |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `lib/jsx_rosetta/pages_routing.rb` | modify | `Scanner` recognizes `_app` as a layout route, not skipped. `Emitter` lists it in the skipped-block-style comment header but doesn't emit it as a `get` line. |
|
|
200
|
+
| `lib/jsx_rosetta/ir/types.rb` | modify | New `IR::LayoutYield` node (no fields). New `mode: :layout` value on Component. |
|
|
201
|
+
| `lib/jsx_rosetta/ir/lowering.rb` | modify | When the function's body is a JSX element wrapping `<Component {...pageProps} />`, lower the inner `<Component ...>` as `LayoutYield`. The wrapping providers stay as ComponentInvocation TODOs (their children include the LayoutYield). |
|
|
202
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | New rendering branch for `IR::LayoutYield` → `yield if block_given?`. When `@rails_view.kind == :layout`, `rails_view_class_name` → `Views::Layouts::Application`. `ruby_path` → `layouts/application.rb`. |
|
|
203
|
+
|
|
204
|
+
### Specs
|
|
205
|
+
|
|
206
|
+
- `_app.tsx` returning bare `<Component {...pageProps} />` → emits `yield if block_given?` body.
|
|
207
|
+
- `_app.tsx` wrapping in providers → providers become ComponentInvocation TODOs, the inner `<Component …>` becomes a yield.
|
|
208
|
+
- Class name and path for layouts.
|
|
209
|
+
|
|
210
|
+
## B1 — `getServerSideProps` / `getStaticProps` capture
|
|
211
|
+
|
|
212
|
+
### Detection
|
|
213
|
+
|
|
214
|
+
After parsing, scan the top-level AST `body` for one of:
|
|
215
|
+
|
|
216
|
+
- `ExportNamedDeclaration` whose `declaration.type` is `FunctionDeclaration` with `id.name` in `{"getServerSideProps", "getStaticProps"}` (sync or async).
|
|
217
|
+
- `ExportNamedDeclaration` whose `declaration.type` is `VariableDeclaration` and the first declarator's `id.name` is in the same set, with `init` an `ArrowFunctionExpression` or `FunctionExpression`.
|
|
218
|
+
|
|
219
|
+
Capture the full source range of the export statement verbatim. Attach to the component via a new `server_data_source:` field on `IR::Component`. When multiple sibling components share the same module (rare for pages but possible), the field attaches only to the default-export component.
|
|
220
|
+
|
|
221
|
+
### Field
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
# server_data_source : ServerDataSource | nil — capture of an exported
|
|
225
|
+
# getServerSideProps / getStaticProps function. Body is
|
|
226
|
+
# preserved verbatim so the human reviewer can port it
|
|
227
|
+
# to the matching Rails controller action. nil for
|
|
228
|
+
# non-page components.
|
|
229
|
+
ServerDataSource = Data.define(:hook_name, :source) do
|
|
230
|
+
include Node
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Phlex emission
|
|
235
|
+
|
|
236
|
+
When `@rails_view` is set and `component.server_data_source` is non-nil, prepend a TODO comment block above the class:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
# TODO: port this to ClaimsController#show:
|
|
240
|
+
#
|
|
241
|
+
# export async function getServerSideProps(ctx) {
|
|
242
|
+
# const { id } = ctx.params;
|
|
243
|
+
# const claim = await fetchClaim(id);
|
|
244
|
+
# return { props: { claim } };
|
|
245
|
+
# }
|
|
246
|
+
#
|
|
247
|
+
# In Rails: load the data in the controller action, set @claim, and the
|
|
248
|
+
# view will read it via the existing props plumbing.
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
If `@rails_view` is not set (non-rails-routes mode), still emit a similar block but referencing "the host controller" rather than a specific name.
|
|
252
|
+
|
|
253
|
+
### Files
|
|
254
|
+
|
|
255
|
+
| Path | Status | Scope |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| `lib/jsx_rosetta/ir/lowering.rb` | modify | New `extract_server_data_source(program)` scans the AST body for the export shapes above. Threads the result into `Component.new(...)` at lower-time. |
|
|
258
|
+
| `lib/jsx_rosetta/ir/types.rb` | modify | New `ServerDataSource` value type. New `server_data_source:` field on `Component`. |
|
|
259
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | New `render_server_data_source_todo(component)` helper. Prepended to the class output above any cva/module-constant prefix. |
|
|
260
|
+
|
|
261
|
+
### Specs
|
|
262
|
+
|
|
263
|
+
- `export async function getServerSideProps(ctx) { ... }` — function declaration form.
|
|
264
|
+
- `export const getServerSideProps = async (ctx) => { ... }` — const-arrow form.
|
|
265
|
+
- `export const getStaticProps = ...` — alternate hook name.
|
|
266
|
+
- Two hooks in the same file — only the first is captured (multi-hook is unusual and overlap is rare; the second still surfaces in module_bindings TODO).
|
|
267
|
+
- View emission prepends the TODO block with the right controller name.
|
|
268
|
+
|
|
269
|
+
## Verification
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
bundle exec rake # rspec + rubocop
|
|
273
|
+
bundle exec exe/jsx_rosetta pages-routes tmp/stress/phlex_out/reserv-web/pages \
|
|
274
|
+
--ext .rb -o /tmp/slice4_routes.rb
|
|
275
|
+
ruby -c /tmp/slice4_routes.rb # must succeed
|
|
276
|
+
bash tmp/run_phlex_stress.sh # full corpus translate
|
|
277
|
+
ruby -c tmp/stress/phlex_out/**/*.rb 2>&1 | grep -v "Syntax OK" | head
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Numbers to track:
|
|
281
|
+
|
|
282
|
+
- routes.rb line count (more lines from namespace blocks, comment headers).
|
|
283
|
+
- Page-component count translating cleanly (currently 82/82 emitted).
|
|
284
|
+
- `ruby -c` pass count (currently 1239/1239).
|
|
285
|
+
- Stress corpus: confirm no regressions on non-page files (B-series is page-router-specific).
|
|
286
|
+
|
|
287
|
+
## Out of scope (deferred or out of umbrella)
|
|
288
|
+
|
|
289
|
+
- HOC unwrapping (#1).
|
|
290
|
+
- Anchor / query-string preservation in href rewriter (#10).
|
|
291
|
+
- Slice C (`<form action>` + ViewComponent/RailsView `--rails-routes`) — separate slice in the umbrella.
|
|
292
|
+
- Translating Next.js `Link` props beyond `href` (`prefetch`, `replace`, etc.).
|
|
293
|
+
- Provider-component recognition inside `_app.tsx`. The wrapper stays as a ComponentInvocation TODO around `yield`; the host is expected to port providers to Rails initializers or Stimulus controllers by hand.
|
|
294
|
+
|
|
295
|
+
## Risks
|
|
296
|
+
|
|
297
|
+
- **B3 breaks downstream route helpers.** Any host code that already references `admin_users_show_path` after slice 1's `as:` addition will need to update to `admin_user_path`. Mitigation: note in CHANGELOG; the rename happens once.
|
|
298
|
+
- **B5 + B3 stacking gets visually busy.** Two nested `namespace` blocks in routes.rb (group + nested) is supported but unusual. Spec covers, but real corpora rarely hit it.
|
|
299
|
+
- **B1 captures large `getServerSideProps` bodies verbatim.** A 200-line server-side handler ends up as a 200-line comment block above the class. Acceptable: the TODO header is clear, and the alternative (silent drop) loses information. Long blocks are a code-review smell, not a translation bug.
|
|
300
|
+
- **B2 layout heuristic is approximate.** `_app.tsx` files that don't follow the standard `<Component {...pageProps} />` shape (e.g. ones that conditionally swap layouts based on `Component.getLayout`) won't emit a `yield`. Mitigation: fall back to emitting the entire body as a TODO with a note that the layout-yield wasn't detected, and let the user wire it.
|
|
301
|
+
- **B4 error pages aren't real Rails routes.** Some Rails apps wire errors via `public/404.html` instead of `config.exceptions_app`. The wiring comment block names both options.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# jsx_rosetta — translator widening + pages-router follow-ups
|
|
2
|
+
|
|
3
|
+
Umbrella plan for ten follow-up items surfaced by the post-merge stress assessment (covers items #2, #3, #4, #5, #6, #7, #8, #9, #11, #12 from the readiness review). Organized into three slices, ordered by independence and impact-per-LOC.
|
|
4
|
+
|
|
5
|
+
Parent plans this builds on: `plans/nextjs_pages_to_rails.md` and its slice 2 / slice 3 spinoffs.
|
|
6
|
+
|
|
7
|
+
Each slice below will get its own implementation plan file when it starts, matching the slice-1/2/3 cadence. This document is the umbrella that organizes the work and locks in the sequencing.
|
|
8
|
+
|
|
9
|
+
## Inventory
|
|
10
|
+
|
|
11
|
+
The ten items, grouped by where the work lands. The cross-references like "(former #2)" map back to the original list in the readiness assessment.
|
|
12
|
+
|
|
13
|
+
### Group A — ExpressionTranslator widening (Slice A)
|
|
14
|
+
|
|
15
|
+
Reduces TODO surface across **every** generated file. Self-contained; no Next.js-specific surface area.
|
|
16
|
+
|
|
17
|
+
- **A1. Simple-condition translator widening** (former #2). Conditional-render guards like `{loading && <X/>}` should emit `if @loading`, not `# TODO: translate condition: loading`. The translator already handles bare identifiers, member chains, and unary `!` in attribute-value context — the conditional-rendering path bails on the same shapes for reasons that need diagnosing. **Stress corpus impact**: ~123 TODOs (61 "render guard couldn't translate" + 41 + 21 simple-condition TODOs).
|
|
18
|
+
|
|
19
|
+
- **A2. Trivial top-level const → Ruby constant** (former #3). Every module-level `const` lands in a TODO block today. Many are `const FOO = "literal"` / `const COLUMNS = [...]` / `const TAGS = {...}` — literal-shaped values that translate cleanly using the same machinery as the merge's cva path. **Stress corpus impact**: ~402 TODO blocks affected.
|
|
20
|
+
|
|
21
|
+
- **A3. `router.push("/path")` hint outside event-handler bodies** (former #6). When the call appears in a synchronous body (not an event handler), emit `# TODO: redirect_to <helper>` referencing the matched URL helper. Handler bodies stay verbatim per the project rule against speculative JS-to-Ruby translation. Smaller surface (<50 occurrences in this corpus) but a high-signal hint when it fires.
|
|
22
|
+
|
|
23
|
+
### Group B — Next.js page-router extensions (Slice 4 of the parent series)
|
|
24
|
+
|
|
25
|
+
Builds on slice 1's `pages-routes` and slice 2's `--rails-routes` view placement. Shipping them together keeps the routes.rb, the view tree, and the controller stubs in sync.
|
|
26
|
+
|
|
27
|
+
- **B1. `getServerSideProps` / `getStaticProps` detection** (former #4). Capture the body at lowering time into a new field on `IR::Component`; emit a TODO comment block at the top of the rails-view file (and a matching one in the controller action stub) with the body verbatim. The only place in slice 4 where AST/IR meaningfully helps.
|
|
28
|
+
|
|
29
|
+
- **B2. `_app.tsx` / `_document.tsx` → application-layout class** (former #5). Stop skipping at the `pages-routes` layer. Translate to `app/views/layouts/application.rb` with class `Views::Layouts::Application < Views::Base`. The merge's auto-yield mechanism is exactly the body shape this needs.
|
|
30
|
+
|
|
31
|
+
- **B3. Namespace nesting for nested dirs** (former #7). `pages/admin/users/[id].tsx` should produce `Admin::UsersController#show` at `/admin/users/:id` (not the current `admin#users_show`). Trigger: more than one non-bracket directory segment before the leaf.
|
|
32
|
+
|
|
33
|
+
- **B4. Error-page support** (former #8). `_error.tsx` / `404.tsx` / `500.tsx` → `Views::Errors::<Status> < Views::Base` at `app/views/errors/<status>.rb`, plus a comment block at the top of routes.rb explaining `config.exceptions_app` wiring.
|
|
34
|
+
|
|
35
|
+
- **B5. Route groups `(group)/`** (former #11). Next.js 13+ pattern where paren-wrapped directories are invisible to the URL but group files semantically. Rule: skip the segment from URL building; carry it through as a controller namespace hint.
|
|
36
|
+
|
|
37
|
+
### Group C — Slice-3 backend expansion (Slice C)
|
|
38
|
+
|
|
39
|
+
Extends slice 3's link-tag reach and the slice-2/3 `--rails-routes` plumbing to the other backends.
|
|
40
|
+
|
|
41
|
+
- **C1. `<form action="...">` rewrite** (former #9). Extend slice 3's link-tag set: `form` joins `a` / `Link` / `NavLink` / `RouterLink`, with `action` instead of `href`. Only rewrite when `method` is GET or absent (HTML default). Slice 1 emits GET routes only, so non-GET forms stay verbatim until the route table grows.
|
|
42
|
+
|
|
43
|
+
- **C2. `--rails-routes` for ViewComponent + RailsView backends** (former #12). The Phlex backend has full slice-2/3 support today; the other two don't. Add equivalent view placement (and href rewriting where it makes sense) to `Backend::ViewComponent` and `Backend::RailsView`.
|
|
44
|
+
|
|
45
|
+
## Slice plans
|
|
46
|
+
|
|
47
|
+
### Slice A — translator widening
|
|
48
|
+
|
|
49
|
+
**Why first**: highest TODO-reduction-per-LOC. Self-contained, no Next.js surface. Lowers visible TODO count on every translation; would lift the stress corpus from ~3.9 TODOs/file mean toward ~2.5.
|
|
50
|
+
|
|
51
|
+
**Likely files**:
|
|
52
|
+
|
|
53
|
+
| Path | Status | Scope |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `lib/jsx_rosetta/backend/view_component/expression_translator.rb` | modify | Accept conditional-render context; return `@ivar` for simple bucket-4 hits with unary `!` prefix preserved. Investigate why the conditional path bails on shapes the attribute-value path accepts — likely a stricter entry point. |
|
|
56
|
+
| `lib/jsx_rosetta/ir/lowering.rb` | modify | New detector for literal-shaped module-level `const` declarations (string, number, boolean, array of literals, object of literals). Mirror the `try_lower_cva_call_site` pattern. |
|
|
57
|
+
| `lib/jsx_rosetta/ir/types.rb` | modify | New `IR::ModuleConstant` value type (parallel to `IR::CvaBinding`). |
|
|
58
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | Emit `IR::ModuleConstant` as a real Ruby constant above the class — reuses the section/ordering of `render_cva_constants`. Also: `router.push("/...")` hit detection in synchronous bodies, emit comment hint using the `@href_rewriter` when present. |
|
|
59
|
+
|
|
60
|
+
**Verification**: stress-corpus re-run with before/after TODO counts; new specs for the three behaviors.
|
|
61
|
+
|
|
62
|
+
### Slice 4 — Next.js page-router extensions
|
|
63
|
+
|
|
64
|
+
**Why second**: every item depends on slice 1 / slice 2 plumbing that already landed. All five items touch `pages-routes` and `translate --rails-routes` together — shipping them as one slice avoids partial-state where the routes.rb expects namespaces but the view tree doesn't (or vice versa).
|
|
65
|
+
|
|
66
|
+
**Behavior change to flag in the slice-4 plan**: namespace nesting changes the route table for any pages tree with multi-segment dirs. Parallel to slice 3's `as:` addition — intentional and documented.
|
|
67
|
+
|
|
68
|
+
**Likely files**:
|
|
69
|
+
|
|
70
|
+
| Path | Status | Scope |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `lib/jsx_rosetta/pages_routing.rb` | modify | (a) Route groups: `Scanner` ignores `(group)` dirs for URL building but stores them in a new `namespace` array on `Route`. (b) Namespace nesting: when more than one non-bracket segment precedes the leaf, treat all but the last as namespaces. `Naming.route_name` and `Emitter.route_line` follow. (c) Stop skipping `_error.tsx` / `404.tsx` / `500.tsx`; emit them as `Route(controller: "errors", action: "<status>", ...)` plus a `config.exceptions_app` comment block at the top of routes.rb. (d) Stop skipping `_app.tsx` / `_document.tsx`; slice 2's view-placement gets a `layout: true` variant. |
|
|
73
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | Accept `layout: true` on the `rails_view:` option (or split into a new `rails_layout:` option — pick at slice-4 plan time). Layout emission uses `Views::Layouts::Application < Views::Base` with the `yield if block_given?` body. |
|
|
74
|
+
| `lib/jsx_rosetta/ir/lowering.rb` + `types.rb` | modify | Capture `export async function getServerSideProps(...)` / `export const getServerSideProps = ...` source verbatim into a new `IR::Component#server_data_source` field. Phlex backend emits as a TODO comment block at the top of the view, pointed at the controller action. |
|
|
75
|
+
|
|
76
|
+
**Verification**: synthetic pages tree exercising each shape (route group, namespace, error page, `_app`, getServerSideProps); stress-corpus pages re-run to confirm the 82 pages still produce valid routes.
|
|
77
|
+
|
|
78
|
+
### Slice C — slice-3 backend expansion
|
|
79
|
+
|
|
80
|
+
**Why third (or interchangeable with slice 4)**: smallest scope. Independent of slice 4 — could ship before, after, or in parallel without conflict.
|
|
81
|
+
|
|
82
|
+
**Likely files**:
|
|
83
|
+
|
|
84
|
+
| Path | Status | Scope |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | Extend `LINK_TAGS` with `form`; restructure `HREF_ATTR_NAMES` so `form` matches `action` rather than `href`. Detect the form's `method` attribute (default GET) and only rewrite when GET. |
|
|
87
|
+
| `lib/jsx_rosetta/backend/view_component.rb` | modify | Accept `rails_view:` + `route_table:` Phlex-style. Output paths follow `app/views/<controller>/<action>.html.erb` (template) + `<action>_component.rb` (class). |
|
|
88
|
+
| `lib/jsx_rosetta/backend/rails_view.rb` | modify | Accept `rails_view:` + `route_table:` Phlex-style. Single-file layout, path becomes `app/views/<controller>/<action>.html.erb`. |
|
|
89
|
+
| `lib/jsx_rosetta.rb` | modify | Allowlist `:rails_view, :route_table` for the ViewComponent and RailsView backends (already there for Phlex). |
|
|
90
|
+
| `lib/jsx_rosetta/cli.rb` | modify | Lift the `--rails-routes requires --as=phlex` guard. |
|
|
91
|
+
|
|
92
|
+
**Verification**: synthetic TSX with `<form action="/x" method="get">`; CLI tests for `translate --as=view --rails-routes <dir>` and `translate --as=view_component --rails-routes <dir>`.
|
|
93
|
+
|
|
94
|
+
## Sequencing
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
slice A ──┐
|
|
98
|
+
├─→ (independent) ─→ slice 4
|
|
99
|
+
slice C ──┘ (only depends on slices 1-3, already landed)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Recommended order**: A first (highest TODO-reduction-per-LOC and broadest reach), then 4 (Next.js-specific value, ships the page-router story end-to-end), then C (smallest scope, additive). Reorder freely if the user prefers — none of the three blocks any other.
|
|
103
|
+
|
|
104
|
+
## Out of scope for this umbrella
|
|
105
|
+
|
|
106
|
+
- **HOC unwrapping** (former #1, deliberately omitted from this plan per user direction). Belongs in its own plan when we're ready to decide on the unwrap rules across `React.memo` / `forwardRef` / `lazy` / `observer` / Redux `connect`.
|
|
107
|
+
- **Anchor / query-string preservation** (former #10, deliberately omitted). Defer until a corpus hits it materially.
|
|
108
|
+
- **User-side gaps** from the readiness assessment — React/Apollo hooks, theme tokens, custom-hooks modules. The gem deliberately doesn't translate these.
|
|
109
|
+
|
|
110
|
+
## Risks
|
|
111
|
+
|
|
112
|
+
- **Slice A condition widening false positives.** If the translator accepts a shape it shouldn't, the conditional renders against an `@ivar` that doesn't exist → render-time NameError. Mitigation: only widen to identifiers and member expressions whose root is in `prop_names` / `local_binding_names`; everything else continues to bail with a TODO. Same five-bucket discipline as the existing translator.
|
|
113
|
+
|
|
114
|
+
- **Slice A const detection over-reaches.** A module-level `const FOO = useSomething()` would emit a Ruby constant referencing a JS expression. Mitigation: only lower constants whose initializer is a *literal* (string / number / boolean / null / array of literals / object of literals). Call expressions, member access, identifiers — all bail to the existing TODO block.
|
|
115
|
+
|
|
116
|
+
- **Slice 4 routes.rb is a breaking change.** Namespace nesting renames route helpers for any pages tree with multi-segment dirs (`/admin/users` → `admin_users_path` rather than `admin_users_show_path`). Document it in the slice-4 plan as a behavior change, parallel to slice 3's `as:` addition. Users with custom `as:` overrides should regenerate.
|
|
117
|
+
|
|
118
|
+
- **Slice C ViewComponent surface is bigger than Phlex's.** ViewComponent's sidecar layout (class + ERB template) means two emission paths instead of one. The slice-2 helpers may need extracting to a shared module before they apply cleanly.
|
|
119
|
+
|
|
120
|
+
- **B1 `getServerSideProps` capture is verbatim, not translated.** Risk: users expect it to "just work" once captured. Mitigation: emit the body explicitly as `# TODO: port this to <controller>#<action>` with a clear header so the expectation is set at the comment level.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Slice A — translator widening (umbrella: `translator_widening_and_pages_followups.md`)
|
|
2
|
+
|
|
3
|
+
Three independent translator changes that reduce TODO noise across every translated file. Self-contained — no Next.js / pages-routing surface area.
|
|
4
|
+
|
|
5
|
+
Items from the umbrella plan:
|
|
6
|
+
|
|
7
|
+
- **A1** — Conditional-render path: simple identifier / member-chain / unary tests over known bindings emit `@ivar` (or a short rendered form) with a single migration TODO, instead of the current `# TODO: translate condition: <expr>` + `if false` fallback. **Stress corpus target: ~123 TODOs (`render guard` ladder collapses + `simple-condition` lines).**
|
|
8
|
+
- **A2** — Literal-shaped module-level `const` declarations (string / number / boolean / null / array of literals / object of literals) lower to `IR::ModuleConstant`. The Phlex backend emits them as real Ruby constants above the class, alongside cva constants. Non-literal initializers still fall through to the existing module-binding TODO block. **Stress corpus target: ~402 TODO blocks affected.**
|
|
9
|
+
- **A3** — `router.push("/path")` references inside `useEffect` / non-handler hook bodies trigger a `# → redirect_to <helper>` annotation above the hook TODO when the route table is present. Handler bodies (`onClick={() => router.push(...)}`) stay verbatim per the project rule. **Stress corpus target: <50 occurrences, high-signal where it fires.**
|
|
10
|
+
|
|
11
|
+
## A1 — Conditional-render widening
|
|
12
|
+
|
|
13
|
+
### Diagnosis
|
|
14
|
+
|
|
15
|
+
`safe_test_expression` in `backend/phlex.rb:894` calls `translator.translate(expression)`. The translator already handles bare identifiers / member chains / unary `!` correctly in *attribute-value* contexts. For *render-condition* contexts the same shapes produce one of two failures:
|
|
16
|
+
|
|
17
|
+
1. **`"nil"` literal result** — bucket-4 hits (known-but-unresolvable locals from hook destructures, top-level `const`, or imports) translate to `"nil"` at the leaf-identifier position so the file loads in attribute-value contexts. Driving `if nil` silently false-arms the branch, so `safe_test_expression` upgrades that to the `false` fallback + TODO.
|
|
18
|
+
|
|
19
|
+
2. **Translator bail (`nil` return)** — for `member-chain root`, `unary operand`, `binary operand`, bucket 4 returns `nil` to bail the whole translation. The conditional then emits `if false` + TODO.
|
|
20
|
+
|
|
21
|
+
In both cases the user reads a verbatim JS expression in a comment and has to wire the Rails side manually.
|
|
22
|
+
|
|
23
|
+
### Widening rule (render-condition context only)
|
|
24
|
+
|
|
25
|
+
Add a new `ExpressionTranslator#translate_condition(source)` entry point. Same recursive translator, but identifier resolution differs for **bucket 4** (known-but-unresolvable locals + imports):
|
|
26
|
+
|
|
27
|
+
| Position | Old behavior | New behavior |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Leaf identifier | `"nil"` | `"@snake_case"` + record-as `condition_promoted_to_prop` |
|
|
30
|
+
| Member-chain root | bail (return nil) | `"@snake_case"` + record + recurse into chain |
|
|
31
|
+
| Unary `!` operand | bail | translate as above, prefix `!` |
|
|
32
|
+
| Binary operand | bail | translate as above |
|
|
33
|
+
|
|
34
|
+
Bucket-3 (props) and bucket-1 (local scope) keep their existing translations. Bucket-2 prop aliases unchanged.
|
|
35
|
+
|
|
36
|
+
The promoted name set comes back via a new field on `Result` (or a sidecar accessor). `safe_test_expression` surfaces the names as a *single, short* TODO line above the `if`, e.g.:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# TODO: render condition `loading` references binding promoted to @loading — thread it as a controller-passed prop
|
|
40
|
+
if @loading
|
|
41
|
+
# …
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
instead of the current
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# TODO: translate condition: loading
|
|
49
|
+
if false
|
|
50
|
+
# …
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Why not just always-widen in attribute-value contexts too
|
|
55
|
+
|
|
56
|
+
Attribute-value uses (`disabled={loading}`) want the bucket-4 leaf to render *something* even when no migration plan exists (the `nil` placeholder is intentional — the page loads and the reviewer fixes it). Promoting to `@loading` everywhere would silently NameError if the user never threads the prop. Render-condition context is the safe place to widen because the test is *load-bearing* — emitting `if false` already destroys the branch's intent, so promoting beats silence.
|
|
57
|
+
|
|
58
|
+
### Files
|
|
59
|
+
|
|
60
|
+
| Path | Status | Scope |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `lib/jsx_rosetta/backend/view_component/expression_translator.rb` | modify | New `translate_condition(source)` entry point + a `@condition_mode` flag that flips bucket-4 behavior. Track promoted names in a per-translation accumulator. |
|
|
63
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | `safe_test_expression` calls the new entry point. `emit_conditional_branches` and `render_guard_ladder_collapse` emit a one-line promoted-binding TODO above the branch (or above the ladder) instead of the verbose verbatim-JS TODO. |
|
|
64
|
+
|
|
65
|
+
### Specs
|
|
66
|
+
|
|
67
|
+
| Path | Status | Coverage |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `spec/backend/view_component/expression_translator_spec.rb` | modify | `translate_condition` results for hook-tuple destructure, top-level import, member-chain over local, unary `!loading`. |
|
|
70
|
+
| `spec/backend/phlex_spec.rb` | modify | Conditional render emits `if @ivar` with TODO header naming the promoted binding. Guard ladder ditto. |
|
|
71
|
+
|
|
72
|
+
## A2 — Literal module-level const → Ruby constant
|
|
73
|
+
|
|
74
|
+
### Detector
|
|
75
|
+
|
|
76
|
+
Mirror `parse_cva_binding` in `lib/jsx_rosetta/ir/lowering.rb`. The detector accepts an `init` node and returns `IR::ModuleConstant` when:
|
|
77
|
+
|
|
78
|
+
- `init.type` is `StringLiteral`, `NumericLiteral`, `BooleanLiteral`, `NullLiteral` — direct literal.
|
|
79
|
+
- `init.type` is `TemplateLiteral` with no interpolations — same as cva's `extract_cva_string`.
|
|
80
|
+
- `init.type` is `ArrayExpression` where **every** element passes the same check recursively, OR is a `SpreadElement` referencing a known-imported binding (defer to the verbatim fallback if any element fails).
|
|
81
|
+
- `init.type` is `ObjectExpression` where every property key is `Identifier` / `StringLiteral`, and every value passes recursively. Computed keys + spread bail to fallback.
|
|
82
|
+
- `init.type` is `TSAsExpression` / `TSSatisfiesExpression` — recurse on the inner expression. Drop the TS annotation; Ruby has no equivalent.
|
|
83
|
+
- `init.type` is `UnaryExpression` with a single `-` operator and a numeric operand — e.g., `const X = -1`.
|
|
84
|
+
|
|
85
|
+
Anything else (`CallExpression`, `MemberExpression`, identifier-referencing arrays/objects) bails to the existing `LocalBinding` path → existing module-bindings TODO block.
|
|
86
|
+
|
|
87
|
+
### IR
|
|
88
|
+
|
|
89
|
+
New value type next to `IR::CvaBinding`:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# A literal-shaped module-level const that lowers to a real Ruby constant.
|
|
93
|
+
# Distinct from LocalBinding (which is captured verbatim as a TODO block)
|
|
94
|
+
# and CvaBinding (which has structured variants/defaults). Use sites that
|
|
95
|
+
# reference this binding's name are unaffected — the imported_names plumbing
|
|
96
|
+
# in build_translator already bails on the bare reference; the constant
|
|
97
|
+
# value just lives above the class.
|
|
98
|
+
#
|
|
99
|
+
# name : String — original JS identifier (e.g. "TAGS", "COLUMNS").
|
|
100
|
+
# constant_name : String — the Ruby constant identifier
|
|
101
|
+
# (`AST::Inflector.underscore(name).upcase`). Stored on the
|
|
102
|
+
# IR so the backend doesn't recompute and so future
|
|
103
|
+
# collision-detection has somewhere to bind.
|
|
104
|
+
# value : Object — the Ruby-side value as a literal-friendly
|
|
105
|
+
# Ruby object (String, Integer, Float, true, false, nil,
|
|
106
|
+
# Array of these, Hash with String keys of these). Backends
|
|
107
|
+
# call `.inspect.freeze` to emit.
|
|
108
|
+
IR::ModuleConstant = Data.define(:name, :constant_name, :value)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Phlex emission
|
|
112
|
+
|
|
113
|
+
Extend `render_module_bindings_prefix` (`backend/phlex.rb:271`) to partition module bindings three ways: `CvaBinding`, `ModuleConstant`, other.
|
|
114
|
+
|
|
115
|
+
`render_module_constants(bindings)` emits a block parallel to `render_cva_constants`:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
TAGS = { "warn" => "...", "error" => "..." }.freeze
|
|
119
|
+
DEFAULT_LIMIT = 50
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The block sits above the class. Use-site references already bail to `nil` at runtime via the existing imported_names plumbing — that stays the same; future work can promote bucket-4 refs to the new constant when names match, but that's out of scope for slice A (it'd duplicate the A1 conditional-promotion work in attribute-value context).
|
|
123
|
+
|
|
124
|
+
### Files
|
|
125
|
+
|
|
126
|
+
| Path | Status | Scope |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `lib/jsx_rosetta/ir/types.rb` | modify | Add `IR::ModuleConstant`. |
|
|
129
|
+
| `lib/jsx_rosetta/ir/lowering.rb` | modify | New `parse_module_constant(init, name)` mirroring `parse_cva_binding`. Plumb into `record_module_binding`. |
|
|
130
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | New `render_module_constants` helper. Three-way partition in `render_module_bindings_prefix`. |
|
|
131
|
+
|
|
132
|
+
### Specs
|
|
133
|
+
|
|
134
|
+
| Path | Status | Coverage |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `spec/ir/lowering_spec.rb` | modify | Each literal shape (string, number, bool, null, array-of-literals, hash-of-literals, template-literal, TS-cast wrapper, unary minus). Non-literal initializers still produce LocalBinding. |
|
|
137
|
+
| `spec/backend/phlex_spec.rb` | modify | Emitted Ruby constants land above the class with the correct name + value. Use sites still emit the existing nil-bail behavior (so the cohabitation is verified). |
|
|
138
|
+
|
|
139
|
+
## A3 — `router.push` hint in non-handler bodies
|
|
140
|
+
|
|
141
|
+
### Detection
|
|
142
|
+
|
|
143
|
+
After lowering, hooks are captured as `IR::ReactHookCall(source: <verbatim JS>, library: :next_js | :react | …)`. For each call whose source contains a `router.push("…")` or `router.push(\`…\`)` invocation:
|
|
144
|
+
|
|
145
|
+
1. Parse the JS argument with `PagesRouting::HrefRewriter.parse_template_source` / literal-extract helpers — *not* with a new parser; reuse the slice-3 helper that already classifies literal vs. template-with-holes vs. unrewritable.
|
|
146
|
+
2. If `@href_rewriter` is set AND the parsed shape matches a route, prepend a hint line above the existing hook TODO block:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
# → redirect_to claim_path(id) (translated from router.push)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
3. If no `@href_rewriter` is set (no route table), still detect the pattern but emit just the descriptive hint:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
# → consider redirect_to <helper> (translated from router.push)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
4. Multiple `router.push` sites in the same hook block produce multiple hint lines.
|
|
159
|
+
|
|
160
|
+
### Why scope to hook bodies
|
|
161
|
+
|
|
162
|
+
`useEffect(() => router.push(...))` is the canonical redirect pattern in Next.js — that's where this fires. Event-handler bodies (extracted to `IR::EventHandler` / `IR::StimulusMethod` body sources) stay verbatim per the project rule against speculative JS-to-Ruby translation. Module-level `if (!user) router.push("/login")` would land in `LocalBinding.source` if it ever appeared at module top-level — out of scope for slice A; the hook-only target covers the realistic cases.
|
|
163
|
+
|
|
164
|
+
### Files
|
|
165
|
+
|
|
166
|
+
| Path | Status | Scope |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `lib/jsx_rosetta/backend/phlex.rb` | modify | New `router_push_hint_lines(hook_source)` helper. Inject at the top of `hook_todo_block_lines`. |
|
|
169
|
+
| `lib/jsx_rosetta/pages_routing.rb` | (possibly) | Expose a `match_path(path_string) → "<helper>(<args>)"` shortcut alongside `HrefRewriter`. Avoid duplicating the parsing logic. |
|
|
170
|
+
|
|
171
|
+
### Specs
|
|
172
|
+
|
|
173
|
+
| Path | Status | Coverage |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `spec/backend/phlex_spec.rb` | modify | `useEffect(() => router.push("/x"))` with route table emits the helper hint; without route table emits the generic hint. Event handler with `router.push` stays unannotated (no hint above stimulus method TODO). |
|
|
176
|
+
|
|
177
|
+
## Sequencing within slice A
|
|
178
|
+
|
|
179
|
+
A2 → A1 → A3. Reasoning:
|
|
180
|
+
|
|
181
|
+
- A2 is the largest TODO-reduction (~402 affected blocks) but the most mechanical — new IR type, new detector, new emitter. Land first to derisk.
|
|
182
|
+
- A1 changes the translator's behavior in a load-bearing way (render decisions). Land second so any regressions are visible against the now-cleaner A2 baseline.
|
|
183
|
+
- A3 is the smallest scope and depends only on existing plumbing. Land last.
|
|
184
|
+
|
|
185
|
+
Each lands as its own commit; the umbrella `Verify Slice A` step at the end runs the full rake + stress suite before patch-level release.
|
|
186
|
+
|
|
187
|
+
## Verification
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
bundle exec rake # rspec + rubocop
|
|
191
|
+
bash tmp/run_phlex_stress.sh # full corpus translate
|
|
192
|
+
# compare tmp/stress/phlex_results.tsv vs. baseline (v0.5.1 stress numbers)
|
|
193
|
+
ruby -c tmp/stress/phlex_out/**/*.rb 2>&1 | grep -v "Syntax OK" | head
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Headline metrics to compare:
|
|
197
|
+
|
|
198
|
+
- Clean-translation count (currently 895/929).
|
|
199
|
+
- Total TODO count (currently ~3.9/file mean across the corpus).
|
|
200
|
+
- Per-category TODO frequency for `translate condition`, `render guard`, `module-level constants`.
|
|
201
|
+
- `ruby -c` pass count (currently 1239/1239).
|
|
202
|
+
|
|
203
|
+
## Risks
|
|
204
|
+
|
|
205
|
+
- **A1 false positives.** Promoting `loading` to `@loading` assumes the user adds the prop; if they don't, render-time NameError. Mitigation: the TODO line names the promoted binding explicitly, so a code review catches it. Existing behavior emits dead `if false` branches that are *also* wrong — A1 trades silent dead code for a NameError with a TODO that points at the fix.
|
|
206
|
+
- **A2 constant-name collisions.** Two siblings in the same file with `const TAGS = …` would emit two `TAGS = …` lines. Lowering already captures module bindings once per program via `lower_all` (each sibling sees the same array by reference), so the existing module-bindings TODO doesn't collide — same property applies here. Spec covers the multi-component-per-file case.
|
|
207
|
+
- **A3 false negatives.** A `router.push` argument we can't classify (`router.push(buildUrl(x))`) silently emits no hint. Acceptable: the existing hook TODO still surfaces the source verbatim; the hint is purely additive.
|
|
208
|
+
- **A2 mutation hazard.** `.freeze` on a hash literal protects the hash, not its values. If a value is itself a mutable array, callers can still mutate the inner array. Not a translation-correctness risk; a Ruby idiom hint at most. Skip deep-freeze.
|