Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1e37c43
codemods: scaffold @stylexjs/codemods package and docs skeleton
ahmedsadid Jul 18, 2026
68be4be
codemods: IR types, rewriter wrapper, seam enforcement tests
ahmedsadid Jul 18, 2026
f130e92
codemods: compile/lint/semantic-diff gates + fixture harness, each ga…
ahmedsadid Jul 18, 2026
d186625
codemods: IR-completeness harness stub (measured coverage, 0/5 at M0)
ahmedsadid Jul 18, 2026
5eec106
codemods: core engine — buildIR (L4) + emit (L7), tested against hand…
ahmedsadid Jul 20, 2026
af9f78b
codemods: Emotion adapter — detect (L2), read (L3), rewrite (L8), imp…
ahmedsadid Jul 20, 2026
4f53b7a
codemods: semantic-diff gate — normalize comma-whitespace in values
ahmedsadid Jul 20, 2026
23201c1
codemods: wire transform into fixture harness; un-skip checks 1 & 4; …
ahmedsadid Jul 20, 2026
595e33e
codemods: real IR-completeness round-trip — stylexToIR reader + measu…
ahmedsadid Jul 20, 2026
a8af71f
codemods: referee (L5) — the two-order agreement check for conditions
ahmedsadid Jul 20, 2026
52cae40
codemods: the flip + hover-guard in emit (L4/L7)
ahmedsadid Jul 20, 2026
521c093
codemods: Emotion adapter parses conditions; transform runs the referee
ahmedsadid Jul 20, 2026
b11a7a3
codemods: M2 fixtures — hover/focus/media/pseudo-element convert; ref…
ahmedsadid Jul 20, 2026
7f972fa
codemods: IR-completeness reader handles conditions — coverage 2/5 ->…
ahmedsadid Jul 20, 2026
c125e2c
codemods: normalize (L6) — physical to logical property mapping
ahmedsadid Jul 21, 2026
b1d0054
codemods: postprocess (L10) — run StyleX's own eslint autofixes on th…
ahmedsadid Jul 21, 2026
e6b51da
codemods: referee guards the sibling-media reorder hazard
ahmedsadid Jul 21, 2026
1dc581a
codemods: wire normalize (before referee) and postprocess (after prin…
ahmedsadid Jul 21, 2026
8620fd4
codemods: M3a fixtures — logical-properties converts; skip-sibling-me…
ahmedsadid Jul 21, 2026
a134d6f
codemods: semantic-diff gate canonicalizes box shorthands to physical…
ahmedsadid Jul 21, 2026
821d7b1
codemods: M3b fixtures — multi-value margin & padding shorthands convert
ahmedsadid Jul 21, 2026
b9e7ea4
codemods: engine support for keyframes + identifier references
ahmedsadid Jul 21, 2026
f9a324f
codemods: Emotion adapter converts object-form keyframes
ahmedsadid Jul 21, 2026
78f70af
codemods: semantic-diff compares @keyframes frame contents; allowlist…
ahmedsadid Jul 21, 2026
b738639
codemods: ADR-0001 — defer theme tokens to M6 (a trusted transformation)
ahmedsadid Jul 22, 2026
dfbf57c
codemods: scoped-postprocess plumbing — postprocess excludeRules, emi…
ahmedsadid Jul 22, 2026
01171d8
codemods: scoped postprocess + registry merge (M4)
ahmedsadid Jul 22, 2026
bf6fb11
codemods: M4 tests + merge fixture
ahmedsadid Jul 22, 2026
3073895
codemods: todos.js (flag reasons + marker) + jsxComment rewriter helper
ahmedsadid Jul 22, 2026
6d12912
codemods: per-site TODO flagging (M5) — bail loudly in place, not who…
ahmedsadid Jul 22, 2026
42eb38e
codemods: M5 fixtures + flagging tests
ahmedsadid Jul 22, 2026
385a988
codemods: make committed TODO reasons timeless (drop roadmap/version …
ahmedsadid Jul 22, 2026
fab77d7
codemods: CLI + dry-run + config loader (M6)
ahmedsadid Jul 22, 2026
f74bd77
codemods: CLI tests + README usage
ahmedsadid Jul 22, 2026
7f5df5b
codemods: M7 hardening — TS gate, TSX output, no-import pragma removal
ahmedsadid Jul 22, 2026
4b20293
codemods: migration guide + v1.0 status (M7)
ahmedsadid Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ module.exports = {
'**/*.d.ts',
'**/pages.gen.ts',
'**/devtools-extension/extension/**',
// codemod fixtures are deliberately non-conforming (broken-on-purpose
// inputs that the correctness gates must fail on)
'**/codemods/__fixtures__/**',
],
overrides: [
{
Expand Down
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module.name_mapper='^@stylexjs/babel-plugin$' -> '<PROJECT_ROOT>/packages/@style
module.name_mapper='^@stylexjs/stylex$' -> '<PROJECT_ROOT>/packages/@stylexjs/stylex/src/stylex.js'
module.name_mapper='^@stylexjs/shared$' -> '<PROJECT_ROOT>/packages/@stylexjs/shared/src/index.js'
module.name_mapper='^style-value-parser$' -> '<PROJECT_ROOT>/packages/style-value-parser/src/index.js'
module.name_mapper='^@stylexjs/eslint-plugin$' -> '<PROJECT_ROOT>/packages/@stylexjs/eslint-plugin/src/index.js'
; type-stubs
module.system.node.resolve_dirname=flow_modules
module.system.node.resolve_dirname=node_modules
Expand Down
17 changes: 17 additions & 0 deletions packages/@stylexjs/codemods/.babelrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module.exports = {
assumptions: {
iterableIsArray: true,
},
presets: [
[
'@babel/preset-env',
{
exclude: ['@babel/plugin-transform-typeof-symbol'],
targets: 'defaults',
},
],
'@babel/preset-flow',
'@babel/preset-react',
],
plugins: [['babel-plugin-syntax-hermes-parser', { flow: 'detect' }]],
};
100 changes: 100 additions & 0 deletions packages/@stylexjs/codemods/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Architecture

The codemod is a source-to-source compiler: styling code in, equivalent
StyleX out. It is built so that supporting a new source library never means
rebuilding the compiler — only writing a new front-end for it.

> **One engine, swappable readers.** A small library-specific **adapter**
> turns one styling library into a neutral style model (the IR). A single
> library-agnostic **core** turns that model into correct, lint-clean
> StyleX. To add Emotion, MUI, or CSS Modules you write an adapter — the
> engine never changes.

## The seam

| | Adapter (per source) | Core engine (shared) |
| --- | --- | --- |
| Owns | detect, read, rewrite call sites, import cleanup | IR build, conflict referee, value normalization, emit, registry, postprocess, gates |
| Knows about | one styling library's syntax | only the IR and StyleX |
| Changes when | a new source is added | StyleX itself gains a feature |

**Load-bearing invariant:** `src/core/` imports *nothing* from
`src/adapters/` and never touches a parser node — it only sees
declarations, the IR, and emitted StyleX fragments. Enforced by
`__tests__/seam-test.js` from commit one. The seam is crossed exactly
twice:

1. **Adapter → core:** a flat list of `declarations`
(`{ context, property, value }`) — "here is what styles exist".
2. **Core → adapter:** the **binding map** — "style site #1 becomes
`styles.button`" — which the adapter places back into that library's
idiom.

A second invariant keeps the AST toolkit swappable: only
`src/core/rewriter.js` may import jscodeshift/recast (also enforced by the
seam test).

## The IR (`src/core/ir.js`)

The IR is shaped like **StyleX's output** (property-grouped, conditions
nested inside a property), not like any source's input. A style either maps
into the IR (convertible) or it does not (flagged — never guessed); the
edge of the IR is the edge of what is automatable.

Its only two open axes are the ones StyleX itself grows along, both modeled
as variant types so growth stays additive:

- `Condition` — `pseudo-class` / `pseudo-element` / `at-rule` (later:
ancestor selectors, `@supports`, `@container`).
- `Value` — `static` and `first-that-works` today; a `dynamic`
(function-form) variant lands in v1.1.

Completeness is **measured, not asserted**: the IR-completeness harness
(`__tests__/ir-completeness-test.js`) round-trips valid StyleX style
objects through a test-only reader and reports a coverage percentage. An
IR gap is safe — in the pipeline the construct is flagged, never
emitted incorrectly.

## The gates (`src/core/gates/`)

Independent correctness checks, used as test assertions and as a runtime
safety net. Each is proven to FAIL on a deliberately-broken input
(`__fixtures__/broken/`) — a gate that never fails is worthless.

- **Compile** (`compile.js`) — output must compile through the real
`@stylexjs/babel-plugin`; also yields the plugin's rule metadata.
- **Lint** (`lint.js`) — output must pass every `@stylexjs/eslint-plugin`
rule at *error* with zero messages (zero autofixes needed).
- **Semantic diff** (`semanticDiff.js`) — net CSS before (from
`@emotion/serialize`, the source library's own ground truth) and after
(from babel-plugin metadata) must match across every condition
combination, minus an explicit allowlist of sanctioned diffs
(hover-guard, physical→logical). Its parsers throw on anything they
cannot provably represent, so an unparsable input can never diff clean
by accident.
- **Render gate** *(future, v2.0)* — Playwright computed-style diff in a
real browser, keeping the semantic-diff gate honest.

## Testing layers

1. **Gate self-tests** — each gate green on the trivial pair AND red on a
broken pair (`__tests__/gates-test.js`).
2. **Fixture tests** — the executable spec. Every
`__fixtures__/emotion/<name>/{input,expected}.js` pair must: match the
transform byte-exactly (after Prettier), compile, lint clean, and pass
the semantic diff (`__tests__/emotion-fixtures-test.js`).
3. **Engine unit tests** — hand-written IR fed to the engine (from M1),
proving the engine is source-neutral.
4. **Seam tests** — the architectural invariants, as assertions.
5. **IR-completeness** — the measured coverage number.

## Layer map

| Layer | Module |
| --- | --- |
| Parse/print wrapper | `src/core/rewriter.js` (jscodeshift, isolated) |
| IR types | `src/core/ir.js` |
| Gates | `src/core/gates/{compile,lint,semanticDiff}.js` |
| Emotion adapter | `src/adapters/emotion/` (M1+) |
| Engine (build IR, referee, normalize, emit) | `src/core/` (M1–M4) |
| CLI & dry-run report | `src/cli/` (M6) |
93 changes: 93 additions & 0 deletions packages/@stylexjs/codemods/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# @stylexjs/codemods

Codemods for migrating styling libraries to [StyleX](https://stylexjs.com) —
Emotion first, built as **one library-agnostic engine with swappable
per-library adapters**.

> **Status: v1.0 (MVP) feature-complete, pre-publish.** The Emotion adapter
> converts object-syntax styles, self-targeting conditions, physical→logical
> properties, multi-value shorthands, and object-form keyframes; merges into
> partially-migrated files; flags what it can't safely convert with `// TODO`
> markers; and ships as a CLI with a dry-run report. Correctness is enforced by
> three gates (compile, lint, semantic-diff) plus a robustness corpus. Nothing
> here is published yet, and the package name/location is pending maintainer
> confirmation.

See the [**Migrating from Emotion to StyleX** guide](./docs/migrating-from-emotion.md)
for usage, what converts/flags/refuses, and known limitations.

## Principles

- **Bail loudly.** Only provably-safe styles are converted. Anything else is
flagged with a `// TODO(stylex-migration): …` comment, and a file is
refused outright when a partial conversion could change rendering.
Wrong-but-plausible output is the one unacceptable result.
- **Gated output.** Every conversion must compile through
`@stylexjs/babel-plugin`, pass `@stylexjs/eslint-plugin` at *error* with
zero autofixes needed, and have semantically identical net CSS (checked
against Emotion's own serializer, minus an explicit allowlist:
hover-guard, physical→logical properties).

## Converts / flags / refuses (planned v1.0 scope)

| Bucket | Patterns |
| --- | --- |
| Converts | static `css={{…}}` / `css({})`; self-targeting pseudo-classes/elements; media queries and nesting; physical→logical properties; multi-value margin/padding shorthands; object-form `keyframes`; fallback arrays |
| Flags *(planned)* | template literals; dynamic styles; `styled(Component)`; out-of-element selectors; `<Global>`; `shouldForwardProp`; `!important` |
| Refuses | any file where partial conversion could change rendering; conflicting cascades (referee disagreement); ≥2 sibling media queries on one property; theme tokens (until M6 — see [ADR-0001](./docs/decisions/0001-tokens-are-a-trusted-transformation-deferred-to-config.md)) |

Theme tokens → `defineVars` is deferred to the configuration milestone (M6):
it is a *trusted* transformation (a token's value is external to the file, so
it cannot be verified by the semantic-diff gate) and requires the
user-authored `resolveValue` config. See ADR-0001.

## Compatibility

| | Version |
| --- | --- |
| Emits StyleX | 0.19.x |
| Reads Emotion | 10–11 (object syntax) |

See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the design and
[`docs/decisions/`](./docs/decisions/) for ADRs.

## Usage

```sh
# DRY RUN by default — previews a convert / refuse / TODO report, writes nothing:
stylex-codemod emotion "src/**/*.jsx"

# Apply the changes:
stylex-codemod emotion "src/**/*.jsx" --write

# List unchanged files and every TODO reason:
stylex-codemod emotion "src/**/*.jsx" --verbose
```

Each file is reported as **convert** (fully, or partially with `+N TODO`s left
in place), **refuse** (a whole-file structural issue, with the reason), or
**skip** (no Emotion / nothing to do). Convertible-but-unsafe styles are left
in place with a `// TODO(stylex-migration): …` marker rather than dropped.

### Config

An optional `stylex-codemod.config.js` (or `--config <path>`) tunes behavior:

```js
module.exports = {
hoverGuard: true, // wrap :hover in @media (hover: hover) (default true)
logicalProperties: true, // map marginLeft -> marginInlineStart, etc. (default true)
};
```

## Development

```sh
cd packages/@stylexjs/codemods
yarn jest # run from the package cwd, not the repo root
yarn build
```

Fixture pairs live in `__fixtures__/emotion/<name>/{input,expected}.js`; set
`UPDATE_STYLEX_CODEMOD_FIXTURES=1` to regenerate expected files when a
change is intentional.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// DELIBERATELY BROKEN: `stylex.create` must be called with a statically
// analyzable object literal — a function call argument makes the real
// @stylexjs/babel-plugin throw. The compile gate must FAIL on this file.
import * as stylex from '@stylexjs/stylex';

function getStyles() {
return { badge: { color: 'red' } };
}

const styles = stylex.create(getStyles());

export default styles;
12 changes: 12 additions & 0 deletions packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// DELIBERATELY BROKEN: compiles, but violates @stylexjs/eslint-plugin rules
// ('colr' is not a valid property; styles are never used). The lint gate
// must FAIL on this file.
import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
badge: { colr: 'red' },
});

export default function Badge() {
return styles != null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
alert: {
color: 'maroon',
fontWeight: 700,
},
});

export default function Alert() {
return <p {...stylex.props(styles.alert)}>Alert</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';
import { css } from '@emotion/react';

export default function Alert() {
return <p css={css({ color: 'maroon', fontWeight: 700 })}>Alert</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';
import { Button } from './Button';

export default function Toolbar() {
return (
/* TODO(stylex-migration): css on a component element */
<Button css={{ color: 'white' }}>Save</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';
import { Button } from './Button';

export default function Toolbar() {
return <Button css={{ color: 'white' }}>Save</Button>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';

// A descendant selector reaches outside the element — not self-targeting,
// so it cannot enter the IR and the file is refused.
export default function List() {
return (
/* TODO(stylex-migration): selector '& > li' is not self-targeting */
<ul css={{ color: 'black', '& > li': { color: 'gray' } }}>
<li>Item</li>
</ul>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';

// A descendant selector reaches outside the element — not self-targeting,
// so it cannot enter the IR and the file is refused.
export default function List() {
return (
<ul css={{ color: 'black', '& > li': { color: 'gray' } }}>
<li>Item</li>
</ul>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';

import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
panel: {
color: 'gray',
fontSize: 14,
},

panel2: {
backgroundColor: {
default: 'white',

'@media (hover: hover)': {
default: null,
':hover': 'navy',
},
},
},
});

export default function Panel() {
return (
<div>
<span {...stylex.props(styles.panel)}>convert me</span>
{/* TODO(stylex-migration): 'color': Emotion source-order and StyleX priority disagree on which conditional value wins when several are active (:focus vs :hover) */}
<button
css={{ ':focus': { color: 'green' }, ':hover': { color: 'blue' } }}
>
flag me
</button>
<a {...stylex.props(styles.panel2)} href="#top">
convert me too
</a>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';

export default function Panel() {
return (
<div>
<span css={{ color: 'gray', fontSize: 14 }}>convert me</span>
<button
css={{ ':focus': { color: 'green' }, ':hover': { color: 'blue' } }}
>
flag me
</button>
<a
css={{
backgroundColor: 'white',
':hover': { backgroundColor: 'navy' },
}}
href="#top"
>
convert me too
</a>
</div>
);
}
Loading
Loading