jsx_rosetta 0.4.0 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +342 -11
  3. data/CLAUDE.md +70 -0
  4. data/README.md +50 -0
  5. data/ROADMAP.md +92 -0
  6. data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
  7. data/lib/jsx_rosetta/ast/inflector.rb +32 -0
  8. data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
  9. data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
  10. data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
  11. data/lib/jsx_rosetta/backend/view_component.rb +261 -31
  12. data/lib/jsx_rosetta/cli.rb +175 -37
  13. data/lib/jsx_rosetta/icons/lucide.json +37 -0
  14. data/lib/jsx_rosetta/icons.rb +44 -0
  15. data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
  16. data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
  17. data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
  18. data/lib/jsx_rosetta/ir/types.rb +264 -19
  19. data/lib/jsx_rosetta/ir.rb +5 -4
  20. data/lib/jsx_rosetta/pages_routing.rb +640 -0
  21. data/lib/jsx_rosetta/version.rb +1 -1
  22. data/lib/jsx_rosetta.rb +8 -6
  23. data/plans/nextjs_pages_to_rails.md +200 -0
  24. data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
  25. data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
  26. data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
  27. data/plans/translator_widening_and_pages_followups.md +120 -0
  28. data/plans/translator_widening_slice_a.md +208 -0
  29. data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
  30. data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
  31. data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
  32. data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
  33. data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
  34. data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
  35. data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
  36. data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
  37. data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
  38. data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
  39. data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
  40. data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
  41. data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
  42. data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
  43. data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
  44. data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
  45. metadata +30 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7b64119bbfe86413da7a779ada7e47cab06088411cb4667be2e79f8cfba3464
4
- data.tar.gz: c1c1ac742642015a6ae7037538a34bf58b42b6e2fc232bedf48f8660b13a640d
3
+ metadata.gz: 161aa59c1e226755b166b5c7366d0c2b5bc1b3d1f55762b1c8c37f551392179a
4
+ data.tar.gz: 826dd0aff4042b710b354b79c0aa912587477c0f7b07892541fa0e1c7bed090b
5
5
  SHA512:
6
- metadata.gz: 44a3dc2fc89b0dbb59ca5026f2cb27cc7f22e498cf125483725e1db07a39db1e45616baff9766be82bc52e443b33bf64180b90175660536c91f4bfc89e5b1926
7
- data.tar.gz: a9c999e35af23ff2aba38a02ff57fc6229bf051dc339db859c341c9c1d607f88278542abd07a742ac84840616a305dcfe48b8bad32767ffbf5fc8d08c45ef7a9
6
+ metadata.gz: ea022328a7f0cc56e87b08064a2aa4cb602254caf66c5f0f511faa295f8823867ba1fcfece9952d8d1fb064993736af21df8d060ef3b20ddd0ff8788d8f65d8c
7
+ data.tar.gz: fa4085f3972ec65ba2062bbed47f936fb0519d37220d6631fd8c427d03edfb2e20ecf3961fc9dd650f6b27d82db43bd2196a3d9e7b3ab44284d06a07b608efac
data/CHANGELOG.md CHANGED
@@ -2,6 +2,344 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Added — translate cva() variant builders into Ruby constants
6
+
7
+ - **`const fooVariants = cva(base, { variants, defaultVariants })`** —
8
+ the dominant variant-builder pattern in shadcn/ui-shaped components —
9
+ used to land as a 40-line TODO comment block above the class, and
10
+ its use-site `cn(fooVariants({ variant }), className)` emitted the
11
+ verbatim JS as a literal string into the `class:` attribute. Both
12
+ paths are now translated end-to-end.
13
+ - Lowering recognizes the `cva(<base>, { variants, defaultVariants,
14
+ compoundVariants })` call shape and records it as a new
15
+ `IR::CvaBinding` node (parallel to `IR::LocalBinding`).
16
+ - Phlex backend renders each `CvaBinding` as three module-level
17
+ Ruby constants alongside the class — `FOO_BASE_CLASS`,
18
+ `FOO_VARIANT_CLASSES` (a frozen hash of axis → option → class
19
+ string), and `FOO_DEFAULT_VARIANTS`.
20
+ - The use-site call `cn(fooVariants({ variant, size }), className)`
21
+ in a `className={...}` attribute now emits a Ruby string
22
+ interpolation against those constants:
23
+ `"#{FOO_BASE_CLASS} #{FOO_VARIANT_CLASSES["variant"][@variant]}
24
+ #{FOO_VARIANT_CLASSES["size"][@size]} #{@class_name}"`.
25
+ - `render_initializer` now uses `defaultVariants` as the Ruby kwarg
26
+ default for any prop name matching a cva axis (so `variant:
27
+ "default"` flows from the cva binding even though the React
28
+ function signature took the prop undefaulted).
29
+ - `compoundVariants` (the rule-of-rules cva feature) is not yet
30
+ translated — the verbatim JS source surfaces as a `# TODO:
31
+ compoundVariants from FOO aren't translated` comment alongside the
32
+ emitted constants so the reviewer can hand-port the rules.
33
+ - Other module-level constants (non-cva) still take the existing
34
+ "# TODO: module-level constants" pre-class comment path.
35
+
36
+ ### Added — drop Slot.Root branch from polymorphic asChild tags
37
+
38
+ - **shadcn's `<Comp asChild>` no longer NameErrors at render.** Components
39
+ using `const Comp = asChild ? Slot : "div"` (or `Slot.Root`) routed
40
+ the truthy branch through Radix's `Slot`, which has no Ruby class on
41
+ the Phlex side. Lowering now detects this Slot-vs-tag conditional
42
+ (Slot rooted at a `radix-ui`/`@radix-ui/react-*` import) and emits
43
+ only the non-Slot branch — no conditional, no `as_child` kwarg,
44
+ just the underlying tag.
45
+ - New CLI flag `--keep-slot` and `JsxRosetta.translate(..., keep_slot:
46
+ true)` preserves the full polymorphic conditional for consumers
47
+ that shim `Components::Slot::Root` themselves.
48
+ - Detection respects the import source: a project-local `import
49
+ { Slot } from "./my-slot"` is untouched.
50
+
51
+ ### Added — map known Radix primitive tags to underlying HTML elements
52
+
53
+ - **`<SeparatorPrimitive.Root />`, `<LabelPrimitive.Root />`, etc.**
54
+ used to lower as `ComponentInvocation`s, producing
55
+ `render SeparatorPrimitive::Root.new(...)` — undefined constants,
56
+ NameError on render. Now: when the import source matches a Radix
57
+ package (`radix-ui`, `@radix-ui/react-*`) and the
58
+ `(LocalName, Member)` pair is in a small registry, lower as an
59
+ HTML Element with the underlying tag and any always-applied
60
+ attributes (`role`, `type`, etc.). Consumer attrs win on collision
61
+ with the registry defaults.
62
+ - Registry lives at `lib/jsx_rosetta/ir/radix_registry.rb`. Covers
63
+ Separator / Label / Avatar (Root/Image/Fallback) / Switch
64
+ (Root/Thumb) / Progress (Root/Indicator) / AspectRatio (Root) /
65
+ ScrollArea (Root/Viewport). Unknown primitives fall through to
66
+ the existing `ComponentInvocation` / TODO behavior.
67
+ - Works with both named-aliased imports
68
+ (`import { Separator as SeparatorPrimitive } from "radix-ui"`)
69
+ and namespace imports
70
+ (`import * as AvatarPrimitive from "@radix-ui/react-avatar"`).
71
+
72
+ ### Added — auto-emit Lucide icon shims as a translation sidecar
73
+
74
+ - **`import { ChevronRight } from "lucide-react"` now lands a working
75
+ `ChevronRight` Phlex class.** Previously the translator carried the
76
+ React import through verbatim, so the generated component contained
77
+ `render ChevronRight.new(...)` referencing a non-existent Ruby class
78
+ — NameError at render time. The Phlex backend now detects Lucide
79
+ imports (`lucide-react`, `lucide`) that are actually used as JSX
80
+ component tags and emits two kinds of sidecar files:
81
+ - `lucide_icon.rb` — a shared `LucideIcon < Phlex::HTML` base.
82
+ - `<icon>.rb` — one file per referenced icon, defining a subclass
83
+ that renders the vendored SVG path data inline. One file per
84
+ icon means Zeitwerk autoloads each cleanly under the consumer's
85
+ `app/components/` (or wherever the output directory points).
86
+ - Vendored path data lives at `lib/jsx_rosetta/icons/lucide.json`
87
+ (the ~35 icons most commonly imported by shadcn/ui sources). Icons
88
+ not in the vendored set emit a class whose `inner_svg` is empty
89
+ with a TODO comment pointing at the lucide.json refresh path.
90
+ - Tolerates both canonical (`ChevronRight`) and legacy `*Icon`
91
+ (`ChevronRightIcon`) names — same path data either way.
92
+ - `--phlex-namespace=Components` wraps every sidecar in the same
93
+ module as the main translation, so the consumer's autoloader
94
+ config doesn't need a special case.
95
+
96
+ ### Added — translate Stimulus controller bodies when safe
97
+
98
+ - **Paste JSX handler bodies into the generated `_controller.js`.**
99
+ Auto-generated Stimulus controllers used to leave the handler body
100
+ as a TODO comment with the verbatim source above an empty
101
+ `clickHandler(event) { // ... }` stub. For DOM-driven handlers (the
102
+ common shape in shadcn-style UI), the JSX body is *already valid JS*
103
+ — we now paste it directly into the method, using the original
104
+ arrow's parameter name so identifier references in the body still
105
+ resolve.
106
+ - `IR::StimulusMethod` gains a `params:` field — the original arrow
107
+ parameter names — used as the Stimulus method's parameter signature.
108
+ - New `safe_to_paste_handler?` heuristic on the Phlex backend bails
109
+ out (falls back to the previous TODO behavior) when the body
110
+ references React state setters (`setX(`), React hooks (`useX(`),
111
+ or is just an identifier-bound `// originally bound to: …` comment.
112
+ - Collision markers are preserved across the new path.
113
+
114
+ ### Fixed — silent children loss on self-closing-with-spread tags
115
+
116
+ - **Auto-yield on blockless spread-children tags.** The shadcn idiom
117
+ `<tag {...props} />` (self-closing JSX whose rest-spread carries React
118
+ `children`) translated to a Phlex `tag(..., **(@props || {}))` call
119
+ with no block — so callers' `Component.new { ... }` blocks were
120
+ silently dropped at render. Now: when an Element or
121
+ ComponentInvocation has no explicit IR children, a `SpreadAttribute`,
122
+ and a non-void tag, emit `tag(...) do; yield if block_given?; end`.
123
+ The `block_given?` guard preserves the no-block use case. Void HTML
124
+ elements (input, img, br, …) still emit blockless. Explicit-children
125
+ paths and `RenderProp` flows are untouched.
126
+
127
+ ## [0.5.1] - 2026-05-11
128
+
129
+ A correctness pass on the v0.5.0 Phlex output. A random-sample review
130
+ surfaced six classes of render-time NameError that `ruby -c` couldn't
131
+ catch (the file parses, but evaluation crashes on a bare reference).
132
+ Each one is now closed at the source — the file loads and at worst
133
+ renders empty where a TODO marker tells the reviewer what to fill in.
134
+ Stress test stays at 895/929 clean and 0/1240 syntax failures; lint
135
+ volume on the generated `.rb` files drops ~38% (5443 → 3373 offenses).
136
+
137
+ ### Fixed — runtime NameErrors at render time
138
+
139
+ - **Destructure leaks downstream of an untranslatable init.** `const
140
+ { fieldValue } = customField` is captured as a TODO, but every use
141
+ site (`fieldValue?.X`, `!fieldValue`, `fieldValue > 0`) used to leak
142
+ as a bare snake_case ref — file loaded, but `field_value&.__typename`
143
+ NameErrored on first render. The translator now bails the whole
144
+ expression when a known-local sits at a member-chain root, on the
145
+ operand side of unary, or on either side of binary. The caller emits
146
+ a `# TODO: translate condition: …` line and falls through to an
147
+ `if false` fallback so the file at least loads.
148
+ - **Inline arrow handlers on PascalCase components.** `const handleClick
149
+ = () => {}` attached to `<Button onClick={handleClick}>` no longer
150
+ leaks as bare `handle_click`. Unconsumed local-arrow names are unioned
151
+ into `Component#local_binding_names`, so use sites emit `on_click: nil`
152
+ instead of a reference to an undefined method.
153
+ - **CamelCase / snake_case rest-prop mismatch.** `**descriptionProps`
154
+ emitted `**descriptionProps` in the initializer signature but the body
155
+ read `**(@description_props || {})` — silently dropped. The rest-name
156
+ is now snake_cased on both sides. `IR::Prop` gains an `alias_name:`
157
+ field so renamed destructures (`"data-testid": dataTestId`) resolve
158
+ the alias to the prop's snake_case ivar at use sites.
159
+ - **Unitless numeric inline styles.** `style={{ marginBottom: 16 }}` used
160
+ to emit `style: 'margin-bottom: 16;'` — invalid CSS, silently ignored
161
+ by browsers. Numeric style values now get a `px` suffix for any
162
+ property not in React's `isUnitlessNumber` table (`zIndex`,
163
+ `lineHeight`, `opacity`, etc. stay bare).
164
+ - **Style declarations rooted at an unresolvable local.** `style={{
165
+ borderRadius: token.borderRadius }}` used to emit `"border-radius:
166
+ #{token.borderRadius};"` and NameError at render. The style
167
+ declaration now drops with a per-decl TODO when its value can't
168
+ translate.
169
+ - **Uppercase / SCREAMING_SNAKE imports in attribute values.** JSX
170
+ `<Foo bar={DEFAULT_PAGE_SIZE}>` used to emit `bar: default_page_size`
171
+ (NameError at the call site). Attribute-position interpolation now
172
+ drops with a TODO when any unresolved identifier starts with
173
+ uppercase — almost always an external import. Lowercase unresolved
174
+ identifiers still pass through unchanged (they may be Rails helpers
175
+ like `current_user`).
176
+
177
+ ### Changed — rubocop output cleanup (5443 → 3373 offenses across 1240 .rb)
178
+
179
+ - **`Lint/MissingSuper`** — initializers emit `super()` to satisfy
180
+ Phlex 2.x's superclass.
181
+ - **`Style/Documentation`** — one-line class docstring above every
182
+ generated class (`# X — generated by jsx_rosetta from JSX. Review
183
+ before shipping.`).
184
+ - **`Style/StringLiterals`** — new helper `AST::Inflector.ruby_string_literal`
185
+ emits single-quoted strings when safe (no embedded single quote,
186
+ backslash, or control char). Non-ASCII like emojis stays in single
187
+ quotes. The translator also re-emits JS string literals (`"hi"` →
188
+ `'hi'`) when the same constraints hold.
189
+ - **`Style/NilComparison` / `Style/NonNilCheck`** — rewrite `x == nil`
190
+ and `x != nil` as `x.nil?` / `!x.nil?` at the binary-translation
191
+ layer.
192
+ - **`Lint/BinaryOperatorWithIdenticalOperands`** — dedupe `||` / `&&`
193
+ when both sides translate to the same Ruby. `value === null ||
194
+ value === undefined` collapses to `@value.nil?` instead of
195
+ `@value.nil? || @value.nil?`.
196
+ - **`Lint/EmptyInterpolation`** — bail on the whole template literal
197
+ when any interpolation segment fails translation or resolves to
198
+ literal `nil`. No more `"prefix-#{}-suffix"`.
199
+ - **`Lint/LiteralAsCondition` / `Lint/DuplicateElsifCondition`** —
200
+ emit a file-level `# rubocop:disable` directive (with a matching
201
+ enable at the bottom of the file) only when an intentional `if false`
202
+ fallback appears. The `# TODO: translate condition:` line above the
203
+ fallback already names the issue, so the cop's report would be
204
+ redundant noise.
205
+ - **`Style/IfInsideElse`** — refactor conditional rendering to chain
206
+ `if / elsif / elsif / else / end` instead of nested
207
+ `if / else / if / else / end`. Cascades into fewer
208
+ `Metrics/BlockNesting`, `Metrics/AbcSize`, and
209
+ `Lint/DuplicateElsifCondition` offenses too.
210
+ - **`Layout/TrailingWhitespace`** — post-process emitted output to
211
+ rstrip every line.
212
+ - **`Layout/FirstHashElementIndentation`** — force-inline object/array
213
+ literals inside `initialize` default values; the column mismatch
214
+ that triggered the cop only happens when the wrap kicks in there.
215
+
216
+ ## [0.5.0] - 2026-05-11
217
+
218
+ Closes the four v0.5.0-candidate items from the v0.4.0 Phlex sample
219
+ review, plus the four larger features queued at the top of the
220
+ roadmap: Apollo + Next.js hook hint translation, class-component
221
+ support, AG-Grid column-descriptor module emission, and pretty-printing
222
+ for long object/array literals.
223
+
224
+ ### Added — class-component support
225
+
226
+ - **`ClassDeclaration` → ViewComponent path.** Classes with a `render()`
227
+ method now lower as components instead of getting flagged with the
228
+ `:class_component` rejection. The `ExpressionTranslator` recognizes
229
+ `this.props.X` and translates to `@x` (plus a snake_case member chain
230
+ for `this.props.X.y.z`); `this.state.X` translates to `nil` since
231
+ there's no Rails-side equivalent without a backing data source. Other
232
+ class members (constructor, lifecycle hooks like
233
+ `componentDidCatch`/`getDerivedStateFromError`, custom event handlers)
234
+ get captured as LocalBinding-style TODO comments at the top of the
235
+ view template so the reviewer sees the verbatim sources. Props are
236
+ synthesized from direct `this.props.X` access AND from
237
+ `const { X } = this.props` destructure patterns — the generated
238
+ `initialize(...)` matches the original class's prop set.
239
+ Stress-test impact: the 4 class-component residuals (ErrorBoundary
240
+ and cousins) now translate cleanly.
241
+
242
+ ### Added — AG-Grid column-descriptor module emission
243
+
244
+ - **Data-factory components.** `export const createColumns = (token,
245
+ sortedInfo) => [{...}, {...}]` now lowers as an `IR::Component` with
246
+ `mode: :data_factory`. Phlex emits a snake_case method that returns
247
+ the translated array — `def create_columns(token: nil, sorted_info: nil)`
248
+ — instead of `view_template`. JSX inside object properties extracts to
249
+ private methods on the class via the existing IR::Lambda path
250
+ (`render: method(:render_id_cell)`). ViewComponent backend emits a
251
+ plain Ruby class with the method and a TODO note (the .erb pair is
252
+ skipped — pure-data classes don't have a template). Multi-positional
253
+ Identifier params are now also supported by `lower_params` — previously
254
+ only single-arg React-style signatures lowered cleanly.
255
+
256
+ ### Added — pretty-printing
257
+
258
+ - **Multi-line layout for long object/array literals.** When the single-
259
+ line rendering of an `IR::ObjectLiteral` or `IR::ArrayLiteral` exceeds
260
+ `LITERAL_INLINE_BUDGET` (80 chars), or contains a nested literal that
261
+ itself wrapped, the layout switches to one entry per line indented
262
+ two spaces past the parent line's indent. Closing bracket re-aligns
263
+ to the parent indent. Short literals (typical Select options, small
264
+ config objects) stay inline so non-AG-Grid output is unchanged. Helps
265
+ readability of the column-descriptor output from the new
266
+ data-factory path.
267
+
268
+ ### Stress test outcome
269
+
270
+ - 929-file Phlex stress rerun: **895/929 clean translations**
271
+ (up from 887/929 in v0.4.0 — 8 additional files now translate via
272
+ the class-component and data-factory paths). **0/1240 emitted `.rb`
273
+ files fail `ruby -c`** (unchanged; 16 more files emitted vs v0.4.0).
274
+ 221/929 files carry an Apollo TODO block (281 GraphQL operation
275
+ names captured); 105/929 carry a Next.js navigation-hook block.
276
+ - 385 specs, all green; rubocop clean.
277
+
278
+ ### Added — framework hook hints
279
+
280
+ - **Apollo data-fetching hooks recognized.** `useQuery`, `useLazyQuery`,
281
+ `useMutation`, `useSubscription`, and `useApolloClient` are now
282
+ detected at lowering time. Each call lands in `Component#react_hooks`
283
+ tagged `library: :apollo`, with the GraphQL operation name extracted
284
+ from a bare-Identifier first argument (`useQuery(GET_USERS_QUERY, …)`
285
+ → `operation: "GET_USERS_QUERY"`). Both backends emit a dedicated
286
+ Apollo TODO block above the template that points at the Rails analog
287
+ (move the fetch to the controller; mutations become form POSTs or
288
+ Turbo Stream responses). Operation names are echoed in the comment so
289
+ the reviewer can match the call back to its GraphQL document.
290
+ Destructured names (`{ data, loading, error }` from `useQuery`,
291
+ `[mutate, { loading }]` from `useMutation`) are captured in
292
+ `local_binding_names` so use sites translate to `nil` placeholders
293
+ instead of raising NameError at render time.
294
+ - **Next.js navigation hooks recognized.** `useRouter`, `usePathname`,
295
+ `useSearchParams`, `useParams`, `useSelectedLayoutSegment`, and
296
+ `useSelectedLayoutSegments` get the same treatment — tagged
297
+ `library: :next_js`, surfaced in a dedicated TODO block listing each
298
+ hook's Rails equivalent (`useRouter` → `redirect_to`; `usePathname`
299
+ → `request.path`; `useSearchParams` / `useParams` → `params`;
300
+ `useSelectedLayoutSegment(s)` → pattern-match `request.path`).
301
+
302
+ The `ReactHookCall` IR type gains `library` and `operation` fields;
303
+ both backends group hooks by library and emit one TODO block per group.
304
+ React-only files keep the unchanged single-block output.
305
+
306
+ ### Added
307
+
308
+ - **`BinaryExpression` and `LogicalExpression` translation.**
309
+ `email.emailAttachments.length > 0` now translates to
310
+ `@email.email_attachments.length > 0` instead of bailing to `if false`.
311
+ Covers `===`/`!==`/`==`/`!=`/`<`/`>`/`<=`/`>=`/`&&`/`||`/`??`. JS-only
312
+ operators map to their Ruby equivalents (`===` → `==`, `??` → `||`).
313
+ Recursive splitting respects nested parens and string literals; outer
314
+ parens are stripped so `(a > b) && c` parses cleanly.
315
+ - **Optional chaining (`?.`) → safe navigation (`&.`).** `user?.profile?.name`
316
+ now emits `@user&.profile&.name` instead of leaving a Ruby SyntaxError
317
+ in member-chain interpolations.
318
+ - **Nested render-function locals extracted to methods.**
319
+ `const renderHeader = () => <h1/>; ... {renderHeader()}` previously
320
+ dropped to `[untranslated: renderHeader()]`. New IR types `RenderMethod`
321
+ and `LocalRenderCall` mean the arrow gets extracted to a private method
322
+ on the Phlex class (`def render_header; h1 do; ...; end`) and the use
323
+ site emits a direct call. Args translate through the same path as
324
+ attribute interpolations. The ViewComponent backend emits a method
325
+ skeleton with a TODO since ERB-method bodies don't translate cleanly
326
+ to Ruby fragments.
327
+
328
+ ### Fixed
329
+
330
+ - **`useCallback` / `useRef` / `useMemo` identifier bindings recognized.**
331
+ `const handleChange = useCallback(...)` followed by
332
+ `onChange={handleChange}` used to emit `on_change: handle_change`
333
+ referencing a nonexistent method. The binding name is now captured in
334
+ `Component#local_binding_names` so the translator emits `nil` at use
335
+ sites, matching the destructure-pattern behavior added in v0.4.0.
336
+ - **Conditional guard on a known local no longer collapses to `if nil`.**
337
+ `error && <X />` where `error` is destructured from a hook used to
338
+ translate to `if nil` (the local-binding placeholder) — Ruby-valid but
339
+ silently never-rendering. Both backends now treat a `"nil"` translation
340
+ as untranslatable, falling through to the TODO-emission path so the
341
+ reviewer sees what to fill in.
342
+
5
343
  ## [0.4.0] - 2026-05-11
6
344
 
7
345
  Closes nine translation gaps identified during a sample review of 12
@@ -94,9 +432,6 @@ generated Ruby parsed but didn't behave like the source JSX.
94
432
 
95
433
  ### Stress test outcome
96
434
 
97
- - 929-file Phlex stress run on `reserv-web`: 887/929 clean translations
98
- (unchanged — rejection logic untouched), **0/1224 syntax failures**
99
- (down from 25 on v0.3.0). All emitted `.rb` files now pass `ruby -c`.
100
435
  - Five residual bugs were caught during the v0.4.0 stress rerun and
101
436
  fixed inline:
102
437
  - Prop default expressions that translated to `nil # TODO: ...` inside
@@ -122,10 +457,8 @@ generated Ruby parsed but didn't behave like the source JSX.
122
457
 
123
458
  ## [0.3.0] - 2026-05-10
124
459
 
125
- Driven by a 929-file stress run against the entire `reserv-web` codebase
126
- (`reserv-web/src/` + `reserv-web/pages/` + `packages/`). Baseline outcome
127
- on v0.2.0: 838/929 (90.2%) clean exit, 91 hard failures across 5 distinct
128
- error categories. This release ships fixes for all five plus a follow-up
460
+ Baseline outcome on v0.2.0: 838/929 (90.2%) clean exit, 91 hard failures across
461
+ 5 distinct error categories. This release ships fixes for all five plus a follow-up
129
462
  that opens up lowercase JSX-returning helpers as components, lifting the
130
463
  corpus to **887/929 (95.5%) clean exit**. The 42 remaining failures are
131
464
  non-component modules (utility/hook libraries, AG-Grid column
@@ -207,10 +540,8 @@ initializers); each now reports a classifier-tagged error that explains
207
540
 
208
541
  ## [0.2.0] - 2026-05-10
209
542
 
210
- Driven by an empirical probe of v0.1.0 against a 39-file Next.js production
211
- slice (`reserv-web/src/components/rolloverbook`). The slice exposed three
212
- return-shape gaps and a crash on nested destructure; this release fixes all
213
- four. Probe outcome: 33/39 → **39/39 emit**.
543
+ The slice exposed three return-shape gaps and a crash on nested destructure;
544
+ this release fixes all four. Probe outcome: 33/39 → **39/39 emit**.
214
545
 
215
546
  ### Fixed
216
547
 
data/CLAUDE.md ADDED
@@ -0,0 +1,70 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ The README has the user-facing pipeline overview, backend trade-offs, and CLI surface. This file covers the things that aren't obvious from the README or a cold read of the source.
6
+
7
+ ## Common commands
8
+
9
+ ```bash
10
+ bin/setup # bundle install + npm install in node/ (required after fresh clone)
11
+ bundle exec rspec # full test suite
12
+ bundle exec rspec spec/backend/phlex_spec.rb # single file
13
+ bundle exec rspec spec/backend/phlex_spec.rb:123 # single example by line
14
+ bundle exec rspec -e "guard-ladder" # examples whose describe/it matches
15
+ bundle exec rubocop
16
+ bundle exec rake # default: rspec + rubocop
17
+ bundle exec exe/jsx_rosetta translate path/X.tsx --as=phlex --phlex-suffix=Component -o /tmp
18
+ ```
19
+
20
+ `bin/setup` is the first-run requirement — the Node sidecar's `node_modules` isn't bundled, and `JsxRosetta.parse` shells out to `node/parse.js`. If `rspec` fails with parser errors, re-run `bin/setup`. `JSX_ROSETTA_NODE` overrides the `node` binary path.
21
+
22
+ `tmp/run_phlex_stress.sh` translates a large external JSX/TSX corpus through the Phlex backend; results land in `tmp/stress/` (gitignored). Use after non-trivial changes to confirm clean-translation count and `ruby -c` pass count don't regress. The TSV at `tmp/stress/phlex_results.tsv` is the headline.
23
+
24
+ ## Pipeline
25
+
26
+ JSX → Babel AST → IR → backend. See README for the user-facing diagram. Hot files:
27
+
28
+ - `lib/jsx_rosetta/ir/lowering.rb` (1700+ lines, single class) — the source of truth for AST→IR. New translation behaviors land here.
29
+ - `lib/jsx_rosetta/ir/types.rb` — `Data.define`'d value classes. `IR::Component` is the root.
30
+ - `lib/jsx_rosetta/backend/view_component/expression_translator.rb` — shared by every backend for JS-expression fragments inside JSX bodies/attributes. Despite the directory name, the Phlex backend uses it too.
31
+ - `lib/jsx_rosetta/backend/phlex.rb` — the most actively maintained backend; most recent improvements target it first.
32
+
33
+ When in doubt, lowering preserves verbatim JS as `IR::Interpolation` so the backend's `ExpressionTranslator` can take a second pass at it.
34
+
35
+ ## ExpressionTranslator identifier resolution (load-bearing)
36
+
37
+ An identifier reference inside JSX is classified into one of five buckets in order. This is the central machinery for closing render-time NameErrors — most recent fixes work by tightening which names land in bucket 4.
38
+
39
+ 1. **Local scope** (pushed via `with_locals` — loop bindings, render-prop params, render-method params) → bare snake_case.
40
+ 2. **Prop alias** (`"data-testid": dataTestId` in destructure) → `@ivar` of the underlying prop name.
41
+ 3. **Prop name** → `@snake_case_ivar`.
42
+ 4. **`local_binding_names` ∪ `imported_names`** (hook tuple destructures, top-level `const`/`function`/`import` declarations) → `nil` at leaf position; **bail** (return nil from the translate call) at member-chain root / unary operand / binary operand. The bail routes the caller into a TODO emission instead of a bare snake_case ref that NameErrors at render.
43
+ 5. **Fallthrough** → bare snake_case identifier, recorded as `unresolved`. Backends drop with TODO on uppercase (PascalCase / SCREAMING) since those are almost always imports; lowercase passes through as a Rails-helper-shaped reference (`current_user` etc).
44
+
45
+ When adding a "we know X exists but can't translate it" class, plumb the names into the translator via `imported_names:` or `local_binding_names:` so bucket 4 fires.
46
+
47
+ ## Project conventions
48
+
49
+ - **Preserve-as-TODO over speculative translation.** Flag `# TODO:` with verbatim source rather than guess. Translator bailout + backend drop-with-TODO helpers exist to keep this safe.
50
+ - **No JS-to-Ruby translation paths that best-guess arbitrary call expressions.** Extending the `ExpressionTranslator` for unambiguous mappings (`===`→`==`, `??`→`||`, etc.) is fine; guessing call semantics is not.
51
+ - **No external-corpus references in committed artifacts.** Specs, fixtures, source comments, commit messages, CHANGELOG, README must stay generic ("the stress corpus" / "a real-world JSX corpus"). Anything under `tmp/` is gitignored and can reference whatever.
52
+
53
+ ## Emission rules that are subtle
54
+
55
+ - **Phlex attribute casing.** HTML-element attrs (lowercase tag) preserve camelCase verbatim — Phlex 2 only converts `_` to `-`, so SVG attrs like `preserveAspectRatio` and `viewBox` must stay camelCase. Component invocations (PascalCase tag) full-snake_case all keys per Ruby kwarg convention. See `plain_attribute_part` in `backend/phlex.rb`.
56
+ - **Dropped attributes are omitted, not nilled.** When a value bails to `nil` with a recorded TODO, the entire kwarg drops from the emission (the TODO above the element is the record). Explicit `attr={null}` in source still emits `attr: nil` (translation succeeded, no TODO → not a drop). Mechanism: `plain_attribute_part` watches whether a TODO was appended during value computation.
57
+ - **Sibling components share `module_bindings` by reference.** `lower_all` attaches the *same* array to every sibling, so backends emit the constants TODO prefix only on the first sibling via `first_emit_for_module_bindings?` (object-identity check on the array).
58
+ - **JSX in non-child positions** lowers through `lower_value_expression` → `lower_jsx_value`. Single-child Fragments unwrap (common React workaround for "single ReactNode required"). Hit by attribute values (`icon={<Foo/>}`) and render lambdas in column-config arrays.
59
+ - **Inline arrow handlers split by tag case.** On PascalCase tags, lower to `IR::EventHandler` (verbatim JS body) → backend extracts a stub instance method (`handle_click`, `handle_change`) and emits `on_click: method(:handle_click)`. On HTML elements, lower to Stimulus method extraction instead (see `stimulus_methods` on `IR::Component`).
60
+
61
+ ## Spec gotchas
62
+
63
+ - `spec/backend/phlex_spec.rb` is the canonical large suite (~1000 lines now) — most behavior changes get their regression cover here.
64
+ - The Phlex spec helper's `files_for` uses **no suffix by default** — paths are `x.rb` (not `x_component.rb`) unless the spec passes `suffix: "Component"`.
65
+ - `spec/fixtures/jsx/*.{jsx,tsx}` + `spec/fixtures/expected/*` feed the Button full-IR-equality test; touch carefully.
66
+ - `spec/.rspec_status` is gitignored and huge — don't read it.
67
+
68
+ ## Commit message style
69
+
70
+ Recent commits use a focused subject + structured body (subject explains *what changed*, body has sections like "Bug fixes:", "Implementation:", "Stress corpus impact:"). When behavior changes shift the stress numbers (clean translations / syntax fails / top dropped-attribute categories), include before/after counts in the body — they're the most useful signal for whether the change earns its complexity.
data/README.md CHANGED
@@ -168,6 +168,56 @@ auto-perform. Common cases:
168
168
  - **Reserved Rails controller names** (in the routes script) → `# WARNING:`
169
169
  line listing collisions.
170
170
 
171
+ ## Resolving TODOs (optional Claude Code skill)
172
+
173
+ The repository ships an optional [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) skill and worker agent that automate the last-mile work of clearing `# TODO:` comments from the generated output. The skill is fully generic — it ships no app-specific assumptions, no design-system data, and no host-stack conventions. You bring those.
174
+
175
+ **What it does:**
176
+
177
+ - A pure-Ruby corpus scanner (`tools/discover_bailouts.rb`) tallies the dropped-expression patterns in your generated output and surfaces likely design-system / config-namespace clusters.
178
+ - A pure-Ruby substitution pass (`tools/apply_substitutions.rb`) reads a YAML you supply (`match:` regex + `tokens:` value table) and splices values into the generated `render Foo.new(...)` calls. Always validates with `ruby -c` before writing; reverts on failure.
179
+ - LLM-driven recipes for React hooks, Apollo data fetching, event handlers, Next.js navigation, and module-level constants. These default to **sharpening** verbose TODOs into single-decision items rather than guessing translations.
180
+ - A `jsx-rosetta-resolve-todo-file` worker agent for parallel fan-out across a corpus.
181
+
182
+ A reference Ant Design v5 token mapping ships under `examples/` — drop-in usable if your source app uses Ant Design with default theming.
183
+
184
+ **Layout in this repo:**
185
+
186
+ ```
187
+ skills/jsx-rosetta-resolve-todos/
188
+ ├── SKILL.md # workflow + routing table
189
+ ├── recipes/ # per-TODO-class recipes
190
+ ├── tools/ # discover_bailouts.rb, apply_substitutions.rb
191
+ ├── data/design_tokens.template.yml # blank schema for your design system
192
+ └── examples/design_tokens.ant_design_v5.yml # reference for Ant-using apps
193
+
194
+ agents/
195
+ └── jsx-rosetta-resolve-todo-file.md # fan-out worker definition
196
+ ```
197
+
198
+ **Installing into your environment:**
199
+
200
+ User-level (available in every Claude Code session on your machine):
201
+
202
+ ```bash
203
+ mkdir -p ~/.claude/skills ~/.claude/agents
204
+ ln -s "$(pwd)/skills/jsx-rosetta-resolve-todos" ~/.claude/skills/
205
+ ln -s "$(pwd)/agents/jsx-rosetta-resolve-todo-file.md" ~/.claude/agents/
206
+ ```
207
+
208
+ Project-level (only available in the consuming Rails app):
209
+
210
+ ```bash
211
+ # from your Rails app's repo root
212
+ mkdir -p .claude/skills .claude/agents
213
+ cp -R /path/to/jsx_rosetta/skills/jsx-rosetta-resolve-todos .claude/skills/
214
+ cp /path/to/jsx_rosetta/agents/jsx-rosetta-resolve-todo-file.md .claude/agents/
215
+ ```
216
+
217
+ In either case, your app-specific configuration (the populated `data/design_tokens.yml`, `data/target_app_conventions.md`, etc.) belongs **inside the consuming app's** `.claude/skills/jsx-rosetta-resolve-todos/data/` — not here. Anything app-specific should be `.gitignore`d in your Rails repo if it shouldn't be shared.
218
+
219
+ The scripts under `tools/` are runnable directly with `ruby` — Claude Code is not required to use them. The recipes and the agent are what require Claude Code.
220
+
171
221
  ## What's deferred
172
222
 
173
223
  - Translating React state primitives (`useState` / `useEffect` etc.). The
data/ROADMAP.md ADDED
@@ -0,0 +1,92 @@
1
+ # Roadmap
2
+
3
+ Forward-looking list of work items. Shipped releases are documented in
4
+ [CHANGELOG.md](CHANGELOG.md); the v0.1.0 design lives in [PLAN.md](PLAN.md).
5
+
6
+ Items are tagged by source so the lineage is traceable:
7
+ - **[review]** — surfaced by a subagent sample review of the Phlex
8
+ stress run (highest signal — these are gaps seen in the wild).
9
+ - **[plan-oos]** — explicitly listed as out-of-scope in a prior release plan.
10
+ - **[stress]** — surfaced by the 929-file stress run rejection logs.
11
+
12
+ ## Next up
13
+
14
+ v0.5.0 ships every roadmap "Larger features" item that was queued. The
15
+ remaining work in this file is Stress-test residual triage and Polish.
16
+
17
+ ## v0.5+ — Larger features
18
+
19
+ _All previously-listed items shipped in v0.5.0. Drop new larger
20
+ features here as they surface._
21
+
22
+ ## Stress-test residuals (42/929 rejected)
23
+
24
+ The non-component-shape rejections each have a classifier-tagged error
25
+ message; most are intentional drops. Worth revisiting if a specific
26
+ shape becomes high-value:
27
+
28
+ - [ ] Custom-hooks modules (`useFoo.ts` returning behavior) —
29
+ classifier says "translate behavior to Stimulus." [stress]
30
+ - [ ] Side-effect-only modules — classifier says "register in a Rails
31
+ initializer." [stress]
32
+ - [ ] Types-only / constants-only modules — currently dropped; could
33
+ emit Ruby constants for the constants subset. [stress]
34
+ - [ ] Mixed-export modules — classifier asks the human to split the
35
+ file. No automated fix planned. [stress]
36
+
37
+ ## Polish / quality
38
+
39
+ - [ ] **Spec coverage gap.** Some v0.4.0 fixes (template-literal
40
+ escaping, multi-line comment prefixing) have one spec each; consider
41
+ fuzzing across a broader corpus.
42
+ - [ ] **README update.** v0.3.0 added the Phlex backend, v0.4.0 closed
43
+ many gaps — README still describes the v0.2.0 ViewComponent-only
44
+ surface. Add a Phlex section and an example of the recursive
45
+ object-literal translation.
46
+ - [ ] **CHANGELOG TLDR.** The v0.4.0 entry is dense; a one-paragraph
47
+ "what this means for users" intro would help.
48
+
49
+ ## Done — historical reference
50
+
51
+ See [CHANGELOG.md](CHANGELOG.md). Major arcs to date:
52
+ - **v0.1.0** — ViewComponent backend, three-stage pipeline.
53
+ - **v0.2.0** — Stimulus extraction, sidecar layout, helpers, RailsView,
54
+ routes, compound components, asChild polymorphism, React hooks.
55
+ - **v0.3.0** — Phlex 2.x backend (three naming strategies), 887/929
56
+ clean translations.
57
+ - **v0.4.0** — Closed nine gaps surfaced by a sample review of v0.3.0
58
+ Phlex output (A, B, D, E, F, G, H, J, K); 0/1224 syntax failures
59
+ (down from 25); 343 specs.
60
+ - **v0.5.0** — Closed all four v0.5.0 candidate items from the v0.4.0
61
+ sample review **plus** every "Larger features" item that was queued:
62
+ - useCallback / useRef / useMemo identifier-bound hook results captured
63
+ in `local_binding_names` so use sites emit `nil` instead of bare
64
+ snake_case refs to nonexistent methods.
65
+ - `BinaryExpression` / `LogicalExpression` translation in the
66
+ `ExpressionTranslator` (incl. `===`/`!==`/`??` mapping).
67
+ - Optional chaining (`?.`) → safe navigation (`&.`) in member chains.
68
+ - Nested render-function locals (`const renderHeader = () => <div/>;
69
+ ... {renderHeader()}`) extracted to private methods on the class.
70
+ - `error && <X/>` guard on a known local no longer collapses to
71
+ `if nil` — falls through to the TODO path.
72
+ - Apollo hooks (`useQuery` / `useLazyQuery` / `useMutation` /
73
+ `useSubscription` / `useApolloClient`) detected with the GraphQL
74
+ operation name extracted from a bare-Identifier first argument;
75
+ emitted as a dedicated TODO block pointing at the Rails controller
76
+ fetch. Stress-test impact: 221/929 files now carry the Apollo block
77
+ (281 operation names captured).
78
+ - Next.js navigation hooks (`useRouter` / `usePathname` /
79
+ `useSearchParams` / `useParams` / `useSelectedLayoutSegment(s)`)
80
+ detected and surfaced in a dedicated TODO block listing each
81
+ hook's Rails analog. Stress-test impact: 105/929 files now carry
82
+ the Next.js block.
83
+ - Class-component support (`render()` method extraction, `this.props.X`
84
+ → `@x` translation, non-render members captured as TODO comments).
85
+ The 4 class-component residuals now translate cleanly.
86
+ - Data-factory components (`export const createColumns = (token) =>
87
+ [{...}]`) lower with `mode: :data_factory`; Phlex emits a snake_case
88
+ method that returns the translated array, with JSX render lambdas
89
+ extracted to private methods.
90
+ - Pretty-printing for long `ObjectLiteral` / `ArrayLiteral` output:
91
+ multi-line layout when single-line exceeds 80 chars; short literals
92
+ stay inline.