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
@@ -15,7 +15,7 @@ module JsxRosetta
15
15
  # source JSX includes inline event handlers, a Stimulus controller is
16
16
  # still emitted as a sibling file alongside the .html.erb.
17
17
  class RailsView < ViewComponent
18
- def emit(component)
18
+ def emit(component, source_filename: nil) # rubocop:disable Lint/UnusedMethodArgument
19
19
  prop_names = component.props.map(&:name)
20
20
  prop_names << component.rest_prop_name if component.rest_prop_name
21
21
  translator = ExpressionTranslator.new(
@@ -22,6 +22,11 @@ module JsxRosetta
22
22
  # lowering time but not modeled in IR) translate to a `nil`
23
23
  # placeholder with an inline `# TODO: local 'name'` marker — the
24
24
  # file still loads, but the reviewer sees what to fill in.
25
+ # * Names in `imported_names` (top-level `import` declarations) are
26
+ # treated the same as local bindings — `nil` at leaf position, bail
27
+ # at member-chain root. Without this, `styles.listContainer` from
28
+ # `import styles from "./X.module.css"` snake-cases to a bare
29
+ # `styles` reference that NameErrors at render time.
25
30
  # * Anything else translates to the bare snake_case identifier and
26
31
  # is recorded as unresolved.
27
32
  #
@@ -29,19 +34,64 @@ module JsxRosetta
29
34
  # entry shadows lower entries.
30
35
  class ExpressionTranslator
31
36
  IDENTIFIER = /\A[a-zA-Z_$][a-zA-Z_$0-9]*\z/
32
- STRING_LITERAL = /\A(['"])(.*)\1\z/m
37
+ # Tighter than `\A(['"])(.*)\1\z/m` — the greedy `.*` previously
38
+ # matched expressions like `"X" ? "Y" : "Z"` as a single quoted
39
+ # string. Now the body excludes unescaped quotes of the same kind,
40
+ # so only true string literals match.
41
+ STRING_LITERAL = /\A(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\z/m
33
42
  NUMBER_LITERAL = /\A-?\d+(\.\d+)?\z/
34
43
  TEMPLATE_LITERAL = /\A`(.*)`\z/m
35
- TEMPLATE_INTERPOLATION = /\$\{([a-zA-Z_$][a-zA-Z_$0-9]*(?:\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\}/
36
- MEMBER_CHAIN = /\A(?<root>[a-zA-Z_$][a-zA-Z_$0-9]*)(?<rest>(?:\.[a-zA-Z_$][a-zA-Z_$0-9]*)+)\z/
44
+ TEMPLATE_INTERPOLATION = /\$\{([a-zA-Z_$][a-zA-Z_$0-9]*(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\}/
45
+ MEMBER_CHAIN = /\A(?<root>[a-zA-Z_$][a-zA-Z_$0-9]*)(?<rest>(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)+)\z/
46
+ MEMBER_SEGMENT = /(\??\.)([a-zA-Z_$][a-zA-Z_$0-9]*)/
47
+ # Class-component access patterns. `this.props.X` is the same shape as
48
+ # a function-component prop reference, so it maps to `@x` plus any
49
+ # trailing member chain. `this.state.X` has no Ruby analog (state
50
+ # without a backing data source) so we surface a `nil` placeholder
51
+ # with a TODO marker — the file still loads.
52
+ THIS_PROPS_CHAIN = /\Athis\.props\.(?<rest>[a-zA-Z_$][a-zA-Z_$0-9]*(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\z/
53
+ THIS_STATE_CHAIN = /\Athis\.state\.(?<rest>[a-zA-Z_$][a-zA-Z_$0-9]*(?:\??\.[a-zA-Z_$][a-zA-Z_$0-9]*)*)\z/
37
54
  UNARY = /\A(?<op>!+|-|\+)(?<operand>.+)\z/m
38
55
  SIMPLE_LITERALS = { "null" => "nil", "undefined" => "nil", "true" => "true", "false" => "false" }.freeze
39
56
 
40
- Result = Data.define(:ruby, :unresolved_identifiers)
57
+ # Binary operators we translate, grouped by precedence (lowest first).
58
+ # We split on the *lowest*-precedence top-level operator and recurse
59
+ # on each side, mirroring how a recursive-descent parser would treat
60
+ # the source: `a > 0 && b < 5` splits on `&&` first, then each side
61
+ # splits on its relational operator.
62
+ #
63
+ # Arithmetic operators (`+`, `-`, `*`, `/`, `%`) aren't included —
64
+ # `-x` and `+x` are unary at the start of an operand, and string-
65
+ # scanning can't disambiguate without operator-state tracking that
66
+ # mirrors a parser. Real JSX conditions rarely need arithmetic in
67
+ # tests; comparison + logical covers the bulk of them.
68
+ BINARY_PRECEDENCE = [
69
+ %w[|| ??],
70
+ %w[&&],
71
+ %w[=== !== == !=],
72
+ %w[<= >= < >]
73
+ ].freeze
41
74
 
42
- def initialize(prop_names:, local_binding_names: [])
75
+ QUOTE_CHARS = ['"', "'", "`"].freeze
76
+ OPEN_BRACKETS = ["(", "[", "{"].freeze
77
+ CLOSE_BRACKETS = [")", "]", "}"].freeze
78
+
79
+ # `promoted_locals` lists names that the condition-mode translator
80
+ # rendered as `@ivar` despite being known-but-unresolvable locals or
81
+ # imports (bucket 4 in the resolution rules). The caller is expected
82
+ # to surface this list in a TODO so a reviewer knows which bindings
83
+ # need to become controller-passed props.
84
+ Result = Data.define(:ruby, :unresolved_identifiers, :promoted_locals)
85
+
86
+ # prop_aliases maps a local-binding name (the alias) to the
87
+ # underlying prop name. `"data-testid": dataTestId` records
88
+ # `{ "dataTestId" => "data-testid" }` so the use site of
89
+ # `dataTestId` resolves to the prop's `@data_testid` ivar.
90
+ def initialize(prop_names:, local_binding_names: [], prop_aliases: {}, imported_names: [])
43
91
  @prop_names = prop_names.to_set
44
92
  @local_binding_names = local_binding_names.to_set
93
+ @prop_aliases = prop_aliases.dup
94
+ @imported_names = imported_names.to_set
45
95
  @local_stack = []
46
96
  end
47
97
 
@@ -53,31 +103,270 @@ module JsxRosetta
53
103
  end
54
104
 
55
105
  def translate(source)
106
+ do_translate(source, condition_mode: false)
107
+ end
108
+
109
+ # Render-condition entry point. Same recursive translator as
110
+ # `translate`, but bucket-4 hits (known-but-unresolvable locals /
111
+ # imports) emit `@snake_case` instead of returning `nil` (member-
112
+ # chain root, unary/binary operand) or `"nil"` (leaf identifier).
113
+ # The promoted names come back via `Result#promoted_locals` so the
114
+ # caller can surface a TODO naming the bindings that need to become
115
+ # controller-passed props.
116
+ #
117
+ # Only safe here because driving an `if` with a known-but-nil value
118
+ # silently disables the branch — destroying the source's intent.
119
+ # Promoting to an ivar trades silence for a clear render-time error
120
+ # (NameError on @ivar if the user never threads the prop) that the
121
+ # accompanying TODO points the reviewer at.
122
+ def translate_condition(source)
123
+ do_translate(source, condition_mode: true)
124
+ end
125
+
126
+ private
127
+
128
+ def do_translate(source, condition_mode:)
56
129
  source = source.strip
57
130
  unresolved = []
131
+ promoted = []
132
+ previous_mode = @condition_mode
133
+ previous_promoted = @promoted_locals
134
+ @condition_mode = condition_mode
135
+ @promoted_locals = promoted
58
136
 
59
137
  ruby = translate_ruby(source, unresolved)
60
- ruby && Result.new(ruby: ruby, unresolved_identifiers: unresolved.uniq)
138
+ ruby && Result.new(
139
+ ruby: ruby,
140
+ unresolved_identifiers: unresolved.uniq,
141
+ promoted_locals: promoted.uniq
142
+ )
143
+ ensure
144
+ @condition_mode = previous_mode
145
+ @promoted_locals = previous_promoted
61
146
  end
62
147
 
63
- private
64
-
65
148
  def translate_ruby(source, unresolved)
66
- if SIMPLE_LITERALS.key?(source) then SIMPLE_LITERALS[source]
67
- elsif source.match?(NUMBER_LITERAL) || source.match?(STRING_LITERAL) then source
68
- elsif source.match?(IDENTIFIER) then translate_identifier(source, unresolved)
69
- elsif (m = MEMBER_CHAIN.match(source)) then translate_member_chain(m[:root], m[:rest], unresolved)
149
+ source = unwrap_outer_parens(source.strip)
150
+ translate_simple_form(source, unresolved) || translate_compound_form(source, unresolved)
151
+ end
152
+
153
+ # Handle the shapes that don't recurse: literals, identifiers, and
154
+ # the class-component `this.props.X` / `this.state.X` accessors.
155
+ # Returns nil when the source needs the compound-form dispatcher.
156
+ def translate_simple_form(source, unresolved)
157
+ return SIMPLE_LITERALS[source] if SIMPLE_LITERALS.key?(source)
158
+ return source if source.match?(NUMBER_LITERAL)
159
+ return reemit_string_literal(source) if source.match?(STRING_LITERAL)
160
+ return translate_this_props_chain(::Regexp.last_match(:rest)) if THIS_PROPS_CHAIN.match(source)
161
+ return translate_this_state_chain(::Regexp.last_match(:rest)) if THIS_STATE_CHAIN.match(source)
162
+ return translate_identifier(source, unresolved) if source.match?(IDENTIFIER)
163
+
164
+ nil
165
+ end
166
+
167
+ # Convert a JS string-literal source (`"foo"` or `'foo'`) into its
168
+ # rubocop-preferred Ruby form: single-quoted when the body has no
169
+ # escapes, embedded quotes of the matching kind, or non-printable
170
+ # characters. Keeps the verbatim source otherwise — JS and Ruby
171
+ # double-quoted escape sequences mostly overlap, so passing through
172
+ # preserves semantics; rewriting `\n` from JS-single-quoted to Ruby
173
+ # would corrupt the literal.
174
+ def reemit_string_literal(source)
175
+ quote = source[0]
176
+ inner = source[1...-1]
177
+ # Backslashes, embedded single quotes, and interpolation markers
178
+ # all complicate the rewrite — keep the original literal as-is.
179
+ # Non-ASCII (emojis, unicode) is fine in single-quoted Ruby
180
+ # strings, so we don't bail for that.
181
+ return source if inner.include?("\\") || inner.include?("'") ||
182
+ inner.include?("\#{") || inner.match?(/[\x00-\x1f\x7f]/)
183
+ return source if quote == "'"
184
+
185
+ "'#{inner}'"
186
+ end
187
+
188
+ # Handle the recursive / multi-segment shapes: member chains,
189
+ # template literals, unary operators, and the binary fallthrough.
190
+ def translate_compound_form(source, unresolved)
191
+ if (m = MEMBER_CHAIN.match(source)) then translate_member_chain(m[:root], m[:rest], unresolved)
70
192
  elsif (m = TEMPLATE_LITERAL.match(source)) then translate_template_literal(m[1], unresolved)
71
- elsif (m = UNARY.match(source))
72
- translate_unary(m[:op], m[:operand], unresolved)
193
+ elsif (m = UNARY.match(source)) then translate_unary(m[:op], m[:operand], unresolved)
194
+ else translate_binary(source, unresolved)
73
195
  end
74
196
  end
75
197
 
198
+ # `this.props.X` → `@x` (plus any trailing member chain segments,
199
+ # snake_cased and with `?.` → `&.`). The first segment IS the prop;
200
+ # subsequent segments are accessed off the prop's value. We don't
201
+ # add to `unresolved` here — class-component prop synthesis at
202
+ # lowering time has already registered the name.
203
+ def translate_this_props_chain(rest)
204
+ first = rest.split(/\??\./, 2).first
205
+ ivar = "@#{AST::Inflector.underscore(first)}"
206
+ remainder = rest[first.length..]
207
+ ruby_rest = remainder.gsub(MEMBER_SEGMENT) do
208
+ op = ::Regexp.last_match(1) == "?." ? "&." : "."
209
+ "#{op}#{AST::Inflector.underscore(::Regexp.last_match(2))}"
210
+ end
211
+ "#{ivar}#{ruby_rest}"
212
+ end
213
+
214
+ # `this.state.X` has no direct Ruby analog — class-component state
215
+ # mutations don't map cleanly to a Phlex render. Emit `nil` so the
216
+ # file loads; reviewers wire up real state via controller-passed
217
+ # props or Stimulus values.
218
+ def translate_this_state_chain(_rest)
219
+ "nil"
220
+ end
221
+
76
222
  def translate_unary(operator, operand, unresolved)
77
- inner = translate_ruby(operand.strip, unresolved)
223
+ operand_clean = operand.strip
224
+ # `!fieldValue` where `fieldValue` is a known-but-unresolved local
225
+ # would translate to `!nil` (always true) and silently flip the
226
+ # condition's truthiness. Bail so the caller emits a TODO with
227
+ # the verbatim source.
228
+ return nil if unresolvable_local?(operand_clean)
229
+
230
+ inner = translate_ruby(operand_clean, unresolved)
78
231
  inner && "#{operator}#{inner}"
79
232
  end
80
233
 
234
+ # Walk source left-to-right looking for a top-level binary operator
235
+ # at the lowest precedence level present. Split there and recurse on
236
+ # each side. When two operators of the same precedence appear (e.g.
237
+ # `a || b || c`), the rightmost is chosen — the recursion on `lhs`
238
+ # then keeps splitting, yielding left-associative grouping.
239
+ def translate_binary(source, unresolved)
240
+ BINARY_PRECEDENCE.each do |operators|
241
+ match = find_top_level_operator(source, operators)
242
+ next unless match
243
+
244
+ result = translate_binary_at(source, match, unresolved)
245
+ return result if result
246
+ end
247
+ nil
248
+ end
249
+
250
+ def translate_binary_at(source, match, unresolved)
251
+ start_idx, end_idx, js_op = match
252
+ lhs = source[0...start_idx].strip
253
+ rhs = source[end_idx..].strip
254
+ return nil if lhs.empty? || rhs.empty?
255
+ # `count > 0` where `count` is a known-but-unresolved local
256
+ # translates to `nil > 0` and NoMethodErrors at render time.
257
+ return nil if unresolvable_local?(lhs) || unresolvable_local?(rhs)
258
+
259
+ lhs_ruby = translate_ruby(lhs, unresolved)
260
+ rhs_ruby = translate_ruby(rhs, unresolved)
261
+ return nil unless lhs_ruby && rhs_ruby
262
+ # `value === null || value === undefined` both translate to
263
+ # `@value.nil?` — collapse idempotent duplication for `||` / `&&`.
264
+ return lhs_ruby if %w[|| &&].include?(js_op) && lhs_ruby == rhs_ruby
265
+
266
+ rewrite_nil_comparison(lhs_ruby, rhs_ruby, js_op) ||
267
+ "#{lhs_ruby} #{ruby_binary_operator(js_op)} #{rhs_ruby}"
268
+ end
269
+
270
+ # `x === null` / `x === undefined` → `x.nil?` (and `!==` → `!x.nil?`).
271
+ # JSX commonly compares values against `null`/`undefined`; emitting
272
+ # the literal `x == nil` form is valid Ruby but trips the
273
+ # Style/NilComparison cop. The `.nil?` form is idiomatic and reads
274
+ # better, so rewrite when either side is literally `nil`.
275
+ def rewrite_nil_comparison(lhs_ruby, rhs_ruby, js_op)
276
+ return nil unless %w[=== !== == !=].include?(js_op)
277
+
278
+ if rhs_ruby == "nil" && lhs_ruby != "nil"
279
+ js_op.start_with?("!") ? "!#{lhs_ruby}.nil?" : "#{lhs_ruby}.nil?"
280
+ elsif lhs_ruby == "nil" && rhs_ruby != "nil"
281
+ js_op.start_with?("!") ? "!#{rhs_ruby}.nil?" : "#{rhs_ruby}.nil?"
282
+ end
283
+ end
284
+
285
+ def ruby_binary_operator(js_op)
286
+ case js_op
287
+ when "===" then "=="
288
+ when "!==" then "!="
289
+ when "??" then "||"
290
+ else js_op
291
+ end
292
+ end
293
+
294
+ # Scan `source` for the rightmost occurrence of any operator from
295
+ # `operators` at lexical top level — outside any (), [], {}, or
296
+ # string literal. Returns `[start_index, end_index, operator]` or nil.
297
+ # Operators are tried longest-first at each position so `>=` beats
298
+ # `>` and `===` beats `==`.
299
+ def find_top_level_operator(source, operators)
300
+ sorted_ops = operators.sort_by { |op| -op.length }
301
+ state = { depth: 0, quote: nil, i: 0, last_match: nil }
302
+ while state[:i] < source.length
303
+ if state[:quote]
304
+ advance_quoted(source, state)
305
+ else
306
+ scan_one_position(source, sorted_ops, state)
307
+ end
308
+ end
309
+ state[:last_match]
310
+ end
311
+
312
+ def advance_quoted(source, state)
313
+ c = source[state[:i]]
314
+ if c == "\\"
315
+ state[:i] += 2
316
+ else
317
+ state[:quote] = nil if c == state[:quote]
318
+ state[:i] += 1
319
+ end
320
+ end
321
+
322
+ def scan_one_position(source, sorted_ops, state)
323
+ c = source[state[:i]]
324
+ if QUOTE_CHARS.include?(c)
325
+ state[:quote] = c
326
+ state[:i] += 1
327
+ return
328
+ end
329
+ state[:depth] += 1 if OPEN_BRACKETS.include?(c)
330
+ state[:depth] -= 1 if CLOSE_BRACKETS.include?(c)
331
+
332
+ matched = state[:depth].zero? && sorted_ops.find { |op| source[state[:i], op.length] == op }
333
+ if matched
334
+ state[:last_match] = [state[:i], state[:i] + matched.length, matched]
335
+ state[:i] += matched.length
336
+ else
337
+ state[:i] += 1
338
+ end
339
+ end
340
+
341
+ # Strip a single layer of outer parens when they wrap the entire
342
+ # source (`(a > b)` → `a > b`). When the leading `(` closes mid-
343
+ # source — e.g. `(a > b) && c` — leave the source alone since the
344
+ # parens are structurally meaningful. Trims surrounding whitespace.
345
+ def unwrap_outer_parens(source)
346
+ return source unless source.start_with?("(") && source.end_with?(")")
347
+ return source unless outer_parens_balanced?(source)
348
+
349
+ source[1...-1].strip
350
+ end
351
+
352
+ def outer_parens_balanced?(source)
353
+ depth = 0
354
+ quote = nil
355
+ source.each_char.with_index do |c, i|
356
+ if quote
357
+ quote = nil if c == quote && source[i - 1] != "\\"
358
+ next
359
+ end
360
+ quote = c if QUOTE_CHARS.include?(c)
361
+ depth += 1 if c == "("
362
+ if c == ")"
363
+ depth -= 1
364
+ return false if depth.zero? && i != source.length - 1
365
+ end
366
+ end
367
+ true
368
+ end
369
+
81
370
  def in_local_scope?(name)
82
371
  @local_stack.any? { |scope| scope.include?(name) }
83
372
  end
@@ -86,35 +375,61 @@ module JsxRosetta
86
375
  snake = AST::Inflector.underscore(name)
87
376
  if in_local_scope?(name)
88
377
  snake
378
+ elsif @prop_aliases.key?(name)
379
+ "@#{AST::Inflector.underscore(@prop_aliases[name])}"
89
380
  elsif @prop_names.include?(name)
90
381
  "@#{snake}"
91
- elsif @local_binding_names.include?(name)
92
- # We know this binding exists locally (destructure, hook tuple)
93
- # but can't model its value. As a leaf identifier, return `nil`
94
- # so the file loads (a bare snake_case ref would NameError).
95
- # As a member-chain root, `nil.member` would NoMethodError at
96
- # render time worse. Fall back to the snake_case bare ref
97
- # and let it surface as a NameError (caller adds an unresolved
98
- # marker), which is at least debuggable. The TODO marker for
99
- # the binding source already lives in the comment block.
100
- if member_chain_root
101
- unresolved << name
102
- snake
103
- else
104
- "nil"
382
+ elsif @local_binding_names.include?(name) || @imported_names.include?(name)
383
+ # We know this binding exists (destructure, hook tuple, top-level
384
+ # import) but can't model its value. In `translate_condition`
385
+ # mode the test is load-bearing emitting `nil` would silently
386
+ # false-arm the branch so promote the binding to an `@ivar`
387
+ # and record it for the caller's TODO. In default mode, return
388
+ # `nil` so the file loads (leaf) / bail so the caller emits a
389
+ # TODO (member-chain root).
390
+ if @condition_mode
391
+ @promoted_locals << name
392
+ return "@#{snake}"
105
393
  end
394
+
395
+ return nil if member_chain_root
396
+
397
+ "nil"
106
398
  else
107
399
  unresolved << name
108
400
  snake
109
401
  end
110
402
  end
111
403
 
404
+ # An identifier we know to be defined locally (destructure, hook
405
+ # tuple) or pulled in via a top-level `import`, but whose value we
406
+ # can't model. The leaf-translates-to-nil path is safe in value
407
+ # positions (attribute kwargs, leaf interpolations) but compound
408
+ # contexts (unary, binary, member chain) must bail so callers emit
409
+ # a TODO instead of silently changing semantics.
410
+ def unresolvable_local?(source)
411
+ return false unless source.match?(IDENTIFIER)
412
+ # In render-condition mode we promote bucket-4 hits to @ivars
413
+ # (see translate_identifier) — so they aren't unresolvable here.
414
+ # Skip the bail so unary/binary translation succeeds.
415
+ return false if @condition_mode
416
+
417
+ (@local_binding_names.include?(source) || @imported_names.include?(source)) &&
418
+ !in_local_scope?(source) &&
419
+ !@prop_names.include?(source)
420
+ end
421
+
112
422
  def translate_member_chain(root, rest, unresolved)
113
423
  translated_root = translate_identifier(root, unresolved, member_chain_root: true)
424
+ return nil unless translated_root
425
+
114
426
  # Underscore each chain segment so JS camelCase identifiers map to
115
- # Ruby snake_case (`post.coverImage` → `post.cover_image`).
116
- ruby_rest = rest.gsub(/\.([a-zA-Z_$][a-zA-Z_$0-9]*)/) do
117
- ".#{AST::Inflector.underscore(::Regexp.last_match(1))}"
427
+ # Ruby snake_case (`post.coverImage` → `post.cover_image`). Map
428
+ # optional-chaining `?.` to Ruby's safe-nav `&.` so a nil receiver
429
+ # short-circuits to nil instead of raising NoMethodError.
430
+ ruby_rest = rest.gsub(MEMBER_SEGMENT) do
431
+ op = ::Regexp.last_match(1) == "?." ? "&." : "."
432
+ "#{op}#{AST::Inflector.underscore(::Regexp.last_match(2))}"
118
433
  end
119
434
  "#{translated_root}#{ruby_rest}"
120
435
  end
@@ -129,7 +444,16 @@ module JsxRosetta
129
444
  match = ::Regexp.last_match
130
445
  literal = content[last_pos...match.begin(0)]
131
446
  parts << escape_ruby_string_literal(literal) unless literal.empty?
132
- parts << "\#{#{translate_template_interpolation(match[1], unresolved)}}"
447
+ translated = translate_template_interpolation(match[1], unresolved)
448
+ # An interpolation segment that itself fails translation (nil)
449
+ # or resolves to literal `nil` (known-but-unresolvable local)
450
+ # would emit `\#{}` / `\#{nil}` — empty or semantically empty
451
+ # interpolation that rubocop flags and that loses the source's
452
+ # intent. Bail so the whole template literal falls through to
453
+ # the caller's TODO path with the verbatim JS source visible.
454
+ return nil if translated.nil? || translated == "nil"
455
+
456
+ parts << "\#{#{translated}}"
133
457
  last_pos = match.end(0)
134
458
  end
135
459
  trailing = content[last_pos..]