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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -11
  3. data/CLAUDE.md +70 -0
  4. data/README.md +50 -0
  5. data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
  6. data/lib/jsx_rosetta/ast/inflector.rb +17 -0
  7. data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
  8. data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
  9. data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
  10. data/lib/jsx_rosetta/backend/view_component.rb +48 -2
  11. data/lib/jsx_rosetta/cli.rb +175 -37
  12. data/lib/jsx_rosetta/icons/lucide.json +37 -0
  13. data/lib/jsx_rosetta/icons.rb +44 -0
  14. data/lib/jsx_rosetta/ir/lowering.rb +720 -31
  15. data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
  16. data/lib/jsx_rosetta/ir/types.rb +187 -3
  17. data/lib/jsx_rosetta/ir.rb +5 -4
  18. data/lib/jsx_rosetta/pages_routing.rb +640 -0
  19. data/lib/jsx_rosetta/version.rb +1 -1
  20. data/lib/jsx_rosetta.rb +8 -6
  21. data/plans/nextjs_pages_to_rails.md +200 -0
  22. data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
  23. data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
  24. data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
  25. data/plans/translator_widening_and_pages_followups.md +120 -0
  26. data/plans/translator_widening_slice_a.md +208 -0
  27. data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
  28. data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
  29. data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
  30. data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
  31. data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
  32. data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
  33. data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
  34. data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
  35. data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
  36. data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
  37. data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
  38. data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
  39. data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
  40. data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
  41. data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
  42. data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
  43. metadata +29 -1
@@ -0,0 +1,74 @@
1
+ # Recipe 01 — Design tokens
2
+
3
+ ## Shape
4
+
5
+ ```
6
+ # TODO: (attribute|style declaration) "<name>" dropped — couldn't translate: <RHS>
7
+ <render Foo.new(...)>
8
+ ```
9
+
10
+ …where `<RHS>` matches a token-system regex you've configured (e.g. `token\.(\w+)` for Ant Design, `theme\.palette\.(\w+)\.main` for MUI, `vars\.colors\.(\w+)` for vanilla-extract).
11
+
12
+ ## Status
13
+
14
+ **Backed by `tools/apply_substitutions.rb`** — pure-Ruby mechanical pass, no LLM.
15
+
16
+ ## Action
17
+
18
+ **Resolve** via `tools/apply_substitutions.rb`. This is a pure-Ruby pass — no LLM call, no agent dispatch.
19
+
20
+ ```bash
21
+ ruby tools/apply_substitutions.rb \
22
+ --config data/design_tokens.yml \
23
+ <generated_components_dir>
24
+ ```
25
+
26
+ Run this **before** any LLM-driven recipe so the corpus is smaller and cheaper to fan out over.
27
+
28
+ ## How the substitution works
29
+
30
+ For each matched TODO, the script:
31
+
32
+ 1. Looks up the captured key in the YAML's `tokens:` map.
33
+ 2. Skips if the entry is missing or has `value: null` (explicitly opted out).
34
+ 3. Locates the next single-line `render <Component>.new(...)` below the TODO.
35
+ 4. Splices:
36
+ - **style declaration** → into the existing string-literal `style:` (or creates a new `style:` kwarg)
37
+ - **attribute** → as a snake_cased kwarg
38
+ 5. Writes only if `ruby -c` passes on the result; otherwise reverts.
39
+
40
+ ## When to skip / sharpen instead
41
+
42
+ The script handles the easy 60–75% of token TODOs. The cases it leaves untouched (and which a downstream recipe should sharpen, not blindly resolve):
43
+
44
+ - **Multi-line render calls.** When the render's argument list spans physical lines or uses hash-form `style: { ... }`, the script bails. Sharpening these requires Ruby-aware editing.
45
+ - **Template literals as RHS.** `` `${token.paddingSM}px ${token.padding}px` `` — multiple token refs woven into a string. Resolving this needs RHS evaluation, which the v1 script doesn't do.
46
+ - **Conditionals as RHS.** `!thumbnailUrl ? token.colorFillQuaternary : undefined` — the resolved color is conditional on runtime state, not a constant.
47
+ - **Component-namespace tokens.** `token.Tag.colorText`, `token.Layout.headerBg` — these depend on the consuming app's `ConfigProvider` overrides and have no safe default.
48
+
49
+ For each, sharpen to a TODO of the form:
50
+
51
+ ```ruby
52
+ # TODO[design_token]: <attr> = <verbatim RHS>. Resolve to your <design system> theme value.
53
+ ```
54
+
55
+ ## Building the YAML for your design system
56
+
57
+ If your source codebase uses a known design system with default theming, check `examples/` first — it may already have a ready-made YAML you can use as-is.
58
+
59
+ Otherwise:
60
+
61
+ 1. Run `tools/discover_bailouts.rb` on the generated corpus.
62
+ 2. Identify the dominant root in the "Repeating member chains" section.
63
+ 3. Copy `data/design_tokens.template.yml` to `data/design_tokens.yml`.
64
+ 4. Paste the suggested `match:` regex.
65
+ 5. For each top key, look up the design system's published default (or your theme override) and add an entry.
66
+ 6. Run `apply_substitutions.rb --dry-run` to preview.
67
+ 7. Drop `--dry-run` to commit the edits.
68
+ 8. Re-run `discover_bailouts.rb` to confirm the count dropped.
69
+
70
+ ## Anti-patterns
71
+
72
+ - **Don't** populate the YAML from training-data guesses about a design system you haven't verified. Token names and defaults change across versions; check the source codebase or the system's docs.
73
+ - **Don't** ship overrides specific to one consuming app in this skill's `data/` directory. App-specific overrides belong in the consuming repo's `.claude/skills/jsx-rosetta-resolve-todos/data/` (gitignored or scoped to that repo).
74
+ - **Don't** auto-resolve when the script reports `parse_failed` — investigate the input. The post-edit revert is a safety net, not a normal-path outcome.
@@ -0,0 +1,49 @@
1
+ # Recipe 02 — Promoted-to-@ivar reminders
2
+
3
+ ## Shape
4
+
5
+ ```
6
+ # TODO: render condition references binding(s) promoted to @ivar — thread as controller-passed prop(s): <name1>, <name2>, ...
7
+ <valid Ruby that uses @<name1>, @<name2>>
8
+ ```
9
+
10
+ ## Status
11
+
12
+ **Backed by `tools/apply_promoted_ivar.rb`** — pure-Ruby mechanical pass, no LLM.
13
+
14
+ ## Action
15
+
16
+ **Resolve / sharpen** via `tools/apply_promoted_ivar.rb`. Pure-Ruby, no LLM.
17
+
18
+ ```bash
19
+ ruby tools/apply_promoted_ivar.rb [--dry-run] [--quiet] <file_or_dir>...
20
+ ```
21
+
22
+ The script:
23
+
24
+ 1. Parses each TODO's named prop list.
25
+ 2. Reads the file's first `def initialize(...)` signature and extracts kwarg names.
26
+ 3. Classifies each name:
27
+ - **camelCase prop** present in `initialize` (after snake_casing) → satisfied
28
+ - **camelCase prop** missing from `initialize` → flag as missing
29
+ - **PascalCase identifier** → flag as external constant needing import/removal (these are class/enum references that `jsx_rosetta` couldn't resolve, not props you'd thread from a controller)
30
+ 4. If every name is satisfied → **delete** the TODO entirely.
31
+ 5. If anything is missing → **sharpen** to a tagged single-liner naming exactly what's missing:
32
+ ```ruby
33
+ # TODO[promoted_ivar]: controller must pass <missing_props> (not in def initialize); external constant(s) <PascalNames> need import or removal.
34
+ ```
35
+
36
+ Always validates with `ruby -c` before writing; reverts on parse failure.
37
+
38
+ ## Why this is mechanical
39
+
40
+ The Ruby below the TODO is already valid and correct. The reason `jsx_rosetta` emits it: when a JSX render condition refers to a name that was a top-level `const` or `import` in the source, it gets promoted to a `@<name>` ivar in Phlex output. The TODO reminds the author to thread that name from the controller. If the author already added it to `initialize`, the reminder is satisfied.
41
+
42
+ ## Expected resolution rate
43
+
44
+ Depends on conversion stage:
45
+
46
+ - **Fresh translation, controllers not yet written** → close to 100% sharpen, 0% resolve. Pure compression win — verbose multi-line reminders become tagged single-liners that are grep-friendly and explicitly call out what each TODO needs (props vs imports).
47
+ - **After controllers are wired** → resolve rate climbs as `initialize` signatures gain the named props. Re-running the script becomes idempotent cleanup: each pass deletes any TODO whose props have caught up.
48
+
49
+ Run this script after `apply_substitutions.rb` and again any time you've updated a controller signature.
@@ -0,0 +1,34 @@
1
+ # Recipe 03 — React hooks
2
+
3
+ ## Shape
4
+
5
+ ```
6
+ # TODO: React hooks detected. None translate automatically.
7
+ # Hotwire/Stimulus handles behavior; controllers/views handle state;
8
+ # turbo-frames handle async loading. Original source:
9
+ # <verbatim hook block>
10
+ ```
11
+
12
+ ## Status
13
+
14
+ **Documented intentions** — recipe describes the recommended LLM-driven action (the sub-classification table below is usable by an agent today); no backing tooling yet. Validation against a real conversion will inform whether parts of this become mechanical.
15
+
16
+ ## Action
17
+
18
+ **Sharpen** by sub-classifying each hook in the dumped block. Default action per hook:
19
+
20
+ | Hook | Sharpened TODO template |
21
+ |---|---|
22
+ | `useState` | `# TODO[useState]: ephemeral UI state \`<name>\` (type <T>). Move to Stimulus controller value.` |
23
+ | `useMemo` | `# TODO[useMemo]: derived value \`<name>\`. Derive in controller and pass as @<name>; or extract to a helper if pure.` |
24
+ | `useRef` | `# TODO[useRef]: DOM ref \`<name>\`. Replace with Stimulus target: data-<controller>-target="<name>".` |
25
+ | `useEffect` | `# TODO[useEffect]: side effect. Split into Stimulus connect/disconnect (DOM lifecycle) or Turbo Frame load (async).` |
26
+ | `useCallback` | (drop — no React render model means no value) |
27
+ | Custom `use*` | `# TODO[custom-hook]: \`<name>\` — review and reimplement; no automatic mapping.` |
28
+
29
+ Preserve the original block as `# Original:` for traceability.
30
+
31
+ ## When to escalate
32
+
33
+ - Hook with significant computation that would change behavior if naively moved
34
+ - Custom hooks that wrap data fetches, subscriptions, or business logic
@@ -0,0 +1,34 @@
1
+ # Recipe 04 — Apollo data-fetching hooks
2
+
3
+ ## Shape
4
+
5
+ ```
6
+ # TODO: Apollo data-fetching hooks detected. None translate automatically.
7
+ # <guidance text>
8
+ # <verbatim useQuery / useMutation block>
9
+ ```
10
+
11
+ ## Status
12
+
13
+ **Documented intentions** — recipe describes the recommended LLM-driven action; no backing tooling yet. The sharpen template + extraction rule below is usable by an agent today, but the GraphQL operation parsing has only been spec'd, not implemented.
14
+
15
+ ## Action
16
+
17
+ **Sharpen.** Recipe is constant: Apollo fetches → Rails controller fetches → component receives data as a prop.
18
+
19
+ Extract the GraphQL operation name and variables from the dumped block, then emit:
20
+
21
+ ```ruby
22
+ # TODO[apollo]: controller fetch — <Operation> with vars { <var1>, <var2> }.
23
+ # In <controller>#<action>, set @<name> = <ResolverClass>.call(<vars>) and
24
+ # pass to this component as <prop_name>: @<name>.
25
+ # Original:
26
+ # <verbatim hook>
27
+ ```
28
+
29
+ Look up `target_app_conventions.yml` for the consuming repo's resolver/service path. If absent, mark `<TBD: see target_app_conventions.yml>` rather than guessing.
30
+
31
+ ## When to escalate
32
+
33
+ - `useMutation` blocks — these typically map to form submits, but the form's HTML structure may need restructuring; sharpen with the operation name and let a human design the form.
34
+ - Optimistic updates / cache writes — these are React-state-of-the-world dependent and don't have a 1:1 Hotwire mapping.
@@ -0,0 +1,45 @@
1
+ # Recipe 05 — Event handlers
2
+
3
+ ## Shape
4
+
5
+ A `handle_*` private method stub with verbatim JS in a TODO:
6
+
7
+ ```ruby
8
+ def handle_click
9
+ # TODO: translate the original JSX `onClick` handler:
10
+ # {
11
+ # <verbatim JS body>
12
+ # }
13
+ end
14
+ ```
15
+
16
+ ## Status
17
+
18
+ **Documented intentions** — recipe describes the recommended LLM-driven action; no backing tooling yet. The classification table below is usable by an agent today.
19
+
20
+ ## Action
21
+
22
+ **Sharpen** by classifying the handler's body, then prescribe a target:
23
+
24
+ | Body shape | Classification | Target |
25
+ |---|---|---|
26
+ | Calls a state setter (`setX(...)`) | behavioral UI state | Stimulus action: `data-action="<event>->controller#<method>"`, body becomes JS in the Stimulus controller |
27
+ | Calls a mutation / fetch | data mutation | Form submit to a Rails action; remove the handler, wire the form's `action=` |
28
+ | Calls `router.push(...)` | navigation | Plain `<a>` or `link_to`; remove the handler |
29
+ | Local computation only, no I/O | behavioral | Stimulus action |
30
+ | Mixed | manual | Escalate with a note |
31
+
32
+ Sharpened TODO sits on the `handle_*` method stub:
33
+
34
+ ```ruby
35
+ def handle_click
36
+ # TODO[event_handler:behavioral]: setIsOpen toggle. Convert to Stimulus action
37
+ # `data-action="click->dropdown#toggle"`; body moves to dropdown_controller.js.
38
+ # Original:
39
+ # <verbatim JS body>
40
+ end
41
+ ```
42
+
43
+ ## Why classification matters
44
+
45
+ Behavioral handlers translate to Stimulus actions; data-mutation handlers translate to form submits. These are very different Rails-side surfaces — the wrong target buys nothing and may hide a real architectural choice the human needs to make.
@@ -0,0 +1,29 @@
1
+ # Recipe 06 — Module-level constants
2
+
3
+ ## Shape
4
+
5
+ ```
6
+ # TODO: module-level constants — translate to Ruby constants or move to a Rails initializer:
7
+ # <verbatim const / function / template-tagged block(s)>
8
+ ```
9
+
10
+ ## Status
11
+
12
+ **Documented intentions** — recipe describes the recommended LLM-driven action; no backing tooling yet. The per-declaration sub-type table below is usable by an agent today.
13
+
14
+ ## Action
15
+
16
+ **Dispatch by sub-type.** Walk the dumped block and classify each declaration:
17
+
18
+ | Sub-type | Action |
19
+ |---|---|
20
+ | `const X = gql(...)` | Sharpen: "GraphQL operation — see recipe 04 (Apollo). Move to controller." |
21
+ | `function foo(...) { ... }` (pure) | Sharpen: "Helper — extract to `app/helpers/<name>_helper.rb` (view-scoped) or `app/services/` (app-wide)." |
22
+ | `const X = lazy(() => import(...))` | Sharpen: "Lazy-loaded component — wrap render in a Turbo Frame `src=` instead." |
23
+ | `const X = { ... } as const` (lookup map) | **Resolve**: emit Ruby `X = { ... }.freeze` at top of file. |
24
+ | `const X = "literal"` / `const X = 42` | **Resolve**: emit Ruby constant or local. |
25
+ | Other | Sharpen with verbatim body and "no recipe — review." |
26
+
27
+ ## Why per-declaration
28
+
29
+ A single `module-level constants` block can contain multiple unrelated things (a gql query AND a lookup table AND a helper function). Splitting per-declaration lets each piece take its proper Rails-shaped destination.
@@ -0,0 +1,44 @@
1
+ # Recipe 07 — Next.js navigation hooks
2
+
3
+ ## Shape
4
+
5
+ ```
6
+ # TODO: Next.js navigation hooks detected. None translate automatically.
7
+ # <guidance text>
8
+ # <verbatim useRouter / usePathname / useSearchParams block>
9
+ ```
10
+
11
+ ## Status
12
+
13
+ **Documented intentions** — recipe describes the recommended LLM-driven action; no backing tooling yet. The sharpen templates below are usable by an agent today.
14
+
15
+ ## Action
16
+
17
+ **Sharpen** with route extraction.
18
+
19
+ For `useRouter().push("/path/[id]", ...)`:
20
+
21
+ ```ruby
22
+ # TODO[nextjs:nav]: navigation — replace with Rails url helper.
23
+ # Source: router.push("/foo/[id]"). Likely target: foo_path(id: <id>).
24
+ # Confirm in config/routes.rb.
25
+ # Original:
26
+ # <verbatim>
27
+ ```
28
+
29
+ For `router.query.<key>` reads:
30
+
31
+ ```ruby
32
+ # TODO[nextjs:query]: read of router.query.<key>. In a controller action,
33
+ # this is params[:<key>]. If this component is rendered from a controller,
34
+ # thread <key> as a prop.
35
+ # Original:
36
+ # <verbatim>
37
+ ```
38
+
39
+ For `usePathname()` / `useSearchParams()`: sharpen similarly with `request.path` / `params` mappings.
40
+
41
+ ## When to escalate
42
+
43
+ - Programmatic navigation tied to async data loading (typically becomes a Turbo Stream response from a controller)
44
+ - Conditional routing that depends on client-side state
@@ -0,0 +1,55 @@
1
+ # Recipe 08 — Generic JS bailouts
2
+
3
+ ## Shape
4
+
5
+ ```
6
+ # TODO: translate JS to Ruby — original:
7
+ # <verbatim JS expression or block>
8
+ ```
9
+
10
+ …or any "<X> dropped" TODO whose RHS doesn't match a configured token regex.
11
+
12
+ ## Status
13
+
14
+ **Documented intentions** — recipe describes the recommended LLM-driven action; no tooling yet. Always-sharpen by design (see below).
15
+
16
+ ## Action
17
+
18
+ **Always sharpen. No resolve whitelist.**
19
+
20
+ This is the largest, most heterogeneous TODO category and the one most prone to "looks easy" hazards. Earlier drafts of this recipe carried a small whitelist (`String(x) → x.to_s`, `x ?? y → x || y`, etc.); on review every entry had at least one observable-behavior divergence:
21
+
22
+ | Tempting JS → Ruby | Why it's wrong |
23
+ |---|---|
24
+ | `Number(x)` → `x.to_f` | JS returns `NaN` on bad input; Ruby returns `0.0` |
25
+ | `Boolean(x)` → `!!x` | JS-falsy `0`, `""` are Ruby-truthy |
26
+ | `String(x)` → `x.to_s` | Diverges on `null`/`undefined` |
27
+ | `x ?? y` → `x \|\| y` | `??` is null/undefined-only; `\|\|` checks all falsy |
28
+ | `Array.isArray(x) ? x[0] : x` → `Array(x).first` | Object inputs become `[k,v]` tuples in Ruby |
29
+
30
+ The gem's hard rule applies here too: **if `jsx_rosetta` bailed, it's not safe**. The gem already attempts every translation that has unambiguous semantics; a generic JS bailout is by definition something it considered unsafe to translate. This recipe inherits that posture.
31
+
32
+ ## Sharpen template
33
+
34
+ Replace the multi-line dump with:
35
+
36
+ ```ruby
37
+ # TODO[js_bailout]: <one-line description of what the source computes>.
38
+ # Move to <controller / helper / Stimulus> — needs <what info>.
39
+ # Original:
40
+ # <verbatim, indented>
41
+ ```
42
+
43
+ If the bailout's intent isn't clear from a single read, just preserve and tag — don't invent a description. A truthful "see Original" is more useful than a guessed summary.
44
+
45
+ ## When to escalate
46
+
47
+ - Bailouts that touch authentication, authorization, or business invariants
48
+ - Bailouts whose Ruby equivalent would change observable behavior
49
+ - Anything whose JS uses APIs without obvious Ruby/Rails equivalents (`navigator.*`, `window.*`, browser-only globals)
50
+
51
+ ## Anti-patterns
52
+
53
+ - **Don't** add to the resolve whitelist. If a future class of bailout has genuinely unambiguous semantics, the right home is the gem itself, not this recipe — the gem will then stop emitting the bailout in the first place.
54
+ - **Don't** translate function calls whose Ruby/Rails equivalent isn't in the consuming app. Sharpen instead so the human can introduce the helper deliberately.
55
+ - **Don't** speculate about author intent. The verbatim JS is the most useful artifact when the worker can't classify with confidence.
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # apply_promoted_ivar.rb
5
+ #
6
+ # Mechanical resolution of jsx_rosetta TODOs of the form:
7
+ # # TODO: render condition references binding(s) promoted to @ivar — thread as controller-passed prop(s): NAME1, NAME2, ...
8
+ #
9
+ # These TODOs are reminders, not defects: the Ruby on the line(s) below
10
+ # them is already valid. They flag "the controller must pass these names
11
+ # as props." If every named prop already appears as a kwarg in the file's
12
+ # `def initialize`, the reminder is satisfied and the TODO can be deleted.
13
+ # Otherwise the script sharpens it to list only the names that are
14
+ # actually missing (or that are PascalCase imports needing different
15
+ # treatment).
16
+ #
17
+ # Always re-parses the post-edit file with `ruby -c`; on failure the file
18
+ # is reverted and reported as `parse_failed`.
19
+ #
20
+ # Usage:
21
+ # apply_promoted_ivar.rb [--dry-run] [--quiet] <file_or_dir>...
22
+
23
+ require 'json'
24
+ require 'set'
25
+ require 'tempfile'
26
+ require 'optparse'
27
+
28
+ TODO_RE = /\A(\s*)# TODO: render condition references binding\(s\) promoted to @ivar — thread as controller-passed prop\(s\): (.+?)\s*\z/.freeze
29
+
30
+ # --- helpers ----------------------------------------------------------------
31
+
32
+ def camel_to_snake(s)
33
+ s.gsub(/([a-z0-9])([A-Z])/) { "#{$1}_#{$2}" }.downcase
34
+ end
35
+
36
+ def pascal_case?(name) = /\A[A-Z]/.match?(name)
37
+
38
+ # Extract the kwarg names from the first `def initialize(...)` in src.
39
+ # Returns a Set of snake_case names. Returns an empty set if there's no
40
+ # initializer or if its arg list can't be parsed.
41
+ def initialize_kwargs(src)
42
+ m = src.match(/def initialize\s*\(/m)
43
+ return Set.new unless m
44
+
45
+ start = m.end(0)
46
+ depth = 1
47
+ i = start
48
+ while i < src.length && depth > 0
49
+ case src[i]
50
+ when '(' then depth += 1
51
+ when ')' then depth -= 1
52
+ end
53
+ i += 1
54
+ end
55
+ return Set.new unless depth == 0
56
+
57
+ args_str = src[start...(i - 1)]
58
+ parse_kwarg_names(args_str).to_set
59
+ end
60
+
61
+ # Given an arg string like "status: nil, options: { a: 1 }, on_change: nil",
62
+ # return the kwarg names (top-level only — ignores nested-hash keys).
63
+ def parse_kwarg_names(s)
64
+ names = []
65
+ depth = 0
66
+ buf = +''
67
+ s.each_char do |c|
68
+ case c
69
+ when '(', '[', '{' then depth += 1; buf << c
70
+ when ')', ']', '}' then depth -= 1; buf << c
71
+ when ','
72
+ if depth.zero?
73
+ names << kwarg_head(buf)
74
+ buf = +''
75
+ else
76
+ buf << c
77
+ end
78
+ else
79
+ buf << c
80
+ end
81
+ end
82
+ names << kwarg_head(buf) unless buf.strip.empty?
83
+ names.compact
84
+ end
85
+
86
+ def kwarg_head(s)
87
+ m = s.match(/\A\s*([a-z_][a-z0-9_]*)\s*:/)
88
+ m && m[1]
89
+ end
90
+
91
+ # --- file processing --------------------------------------------------------
92
+
93
+ def process_file(path)
94
+ src = File.read(path)
95
+ init_set = initialize_kwargs(src)
96
+
97
+ out = []
98
+ resolved = 0
99
+ sharpened = 0
100
+
101
+ src.lines.each do |line|
102
+ if (m = TODO_RE.match(line))
103
+ indent = m[1]
104
+ raw_names = m[2].split(',').map(&:strip).reject(&:empty?)
105
+
106
+ missing_props = []
107
+ missing_imports = []
108
+ raw_names.each do |name|
109
+ if pascal_case?(name)
110
+ missing_imports << name
111
+ else
112
+ snake = camel_to_snake(name)
113
+ missing_props << name unless init_set.include?(snake)
114
+ end
115
+ end
116
+
117
+ if missing_props.empty? && missing_imports.empty?
118
+ resolved += 1
119
+ next
120
+ end
121
+
122
+ parts = []
123
+ parts << "controller must pass #{missing_props.join(', ')} (not in def initialize)" if missing_props.any?
124
+ parts << "external constant(s) #{missing_imports.join(', ')} need import or removal" if missing_imports.any?
125
+ out << "#{indent}# TODO[promoted_ivar]: #{parts.join('; ')}.\n"
126
+ sharpened += 1
127
+ else
128
+ out << line
129
+ end
130
+ end
131
+
132
+ return { file: path, resolved: 0, sharpened: 0 } if resolved.zero? && sharpened.zero?
133
+
134
+ new_content = out.join
135
+ ok = Tempfile.create(['validate', '.rb']) do |tf|
136
+ tf.write(new_content); tf.flush
137
+ system('ruby', '-c', tf.path, out: File::NULL, err: File::NULL)
138
+ end
139
+
140
+ unless ok
141
+ return { file: path, resolved: 0, sharpened: 0, parse_failed: true }
142
+ end
143
+
144
+ { file: path, resolved: resolved, sharpened: sharpened, new_content: new_content }
145
+ end
146
+
147
+ # --- CLI --------------------------------------------------------------------
148
+
149
+ opts = { dry_run: false, quiet: false }
150
+ OptionParser.new do |o|
151
+ o.banner = "usage: apply_promoted_ivar.rb [--dry-run] [--quiet] <file_or_dir>..."
152
+ o.on('--dry-run') { opts[:dry_run] = true }
153
+ o.on('--quiet') { opts[:quiet] = true }
154
+ end.parse!(ARGV)
155
+
156
+ abort "no input files" if ARGV.empty?
157
+
158
+ paths = ARGV.flat_map do |arg|
159
+ if File.directory?(arg)
160
+ Dir.glob(File.join(arg, '**', '*.rb'))
161
+ elsif File.file?(arg)
162
+ [arg]
163
+ else
164
+ warn "skip: #{arg}"; []
165
+ end
166
+ end
167
+
168
+ totals = { files: paths.length, modified: 0, resolved: 0, sharpened: 0, parse_failed: 0 }
169
+ paths.each do |p|
170
+ r = process_file(p)
171
+ totals[:resolved] += r[:resolved]
172
+ totals[:sharpened] += r[:sharpened]
173
+
174
+ if r[:parse_failed]
175
+ totals[:parse_failed] += 1
176
+ puts JSON.generate(file: p, parse_failed: true) unless opts[:quiet]
177
+ elsif r[:new_content]
178
+ totals[:modified] += 1
179
+ File.write(p, r[:new_content]) unless opts[:dry_run]
180
+ puts JSON.generate(file: p, resolved: r[:resolved], sharpened: r[:sharpened]) unless opts[:quiet]
181
+ end
182
+ end
183
+
184
+ puts ''
185
+ puts "files scanned: #{totals[:files]}"
186
+ puts "files modified: #{totals[:modified]}#{opts[:dry_run] ? ' (dry-run)' : ''}"
187
+ puts "TODOs resolved: #{totals[:resolved]}"
188
+ puts "TODOs sharpened: #{totals[:sharpened]}"
189
+ puts "parse failures: #{totals[:parse_failed]}"