jsx_rosetta 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +17 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
- data/lib/jsx_rosetta/backend/view_component.rb +48 -2
- data/lib/jsx_rosetta/cli.rb +175 -37
- data/lib/jsx_rosetta/icons/lucide.json +37 -0
- data/lib/jsx_rosetta/icons.rb +44 -0
- data/lib/jsx_rosetta/ir/lowering.rb +720 -31
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +187 -3
- data/lib/jsx_rosetta/ir.rb +5 -4
- data/lib/jsx_rosetta/pages_routing.rb +640 -0
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +8 -6
- data/plans/nextjs_pages_to_rails.md +200 -0
- data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
- data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
- data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
- data/plans/translator_widening_and_pages_followups.md +120 -0
- data/plans/translator_widening_slice_a.md +208 -0
- data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
- data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
- data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
- data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
- data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
- data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
- metadata +29 -1
|
@@ -0,0 +1,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]}"
|