Skip to content

[codemods] CLI (dry-run by default) + hardening for real-world inputs (Emotion→StyleX, 6/6) -#1773

Open
ahmedsadid wants to merge 36 commits into
facebook:mainfrom
ahmedsadid:codemod/emotion-m7-harden
Open

[codemods] CLI (dry-run by default) + hardening for real-world inputs (Emotion→StyleX, 6/6) -#1773
ahmedsadid wants to merge 36 commits into
facebook:mainfrom
ahmedsadid:codemod/emotion-m7-harden

Conversation

@ahmedsadid

Copy link
Copy Markdown
Contributor

What changed / motivation ?

Part of migrating Emotion styles to StyleX (#1766). The earlier PRs built the
conversion engine; this one makes it usable from the command line and hardens it
against messy real-world input. Last PR in the series — it completes the v1.0
MVP.

# dry run by default — prints a convert / refuse / skip report, writes nothing
stylex-codemod emotion "src/**/*.jsx"

# apply the changes
stylex-codemod emotion "src/**/*.jsx" --write
  • CLI. Runs the codemod over a file glob. Dry run by default: you get a
    per-file report (convert / refuse / skip, with TODO counts and reasons) and
    nothing is written until you pass --write. --verbose lists every TODO.
  • Optional config. A stylex-codemod.config.js toggles behavior like the
    :hover media guard and the physical → logical mapping.
  • TypeScript. .tsx files are parsed and re-emitted as .tsx.
  • Robustness corpus. A batch of realistic and awkward inputs runs through the
    transform to lock in the invariants that must hold on any input: it never
    throws, converted output always compiles and lints, and anything it skips is
    left byte-for-byte unchanged. This caught and fixed two real edge cases (JSX
    pragma removal and object-literal reprinting).

Testing

  • CLI tests: dry run writes nothing, --write applies changes, the report counts
    are correct, and an unknown adapter errors out.
  • The robustness corpus described above.

Linked PR/Issues

Part of #1766 · builds on #1772

Additional Context

Stacked on #1772, and the final PR in the series. Also adds a "Migrating from
Emotion" guide to the package docs. Stands on its own to review.

Pre-flight checklist

  • I have read the contributing guidelines
  • Performed a self-review of my code

ahmedsadid and others added 30 commits July 23, 2026 17:03
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…te proven to fail on a broken pair

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-written IR

Source-neutral: emit is fed hand-written IR (no Emotion) so the engine is
provably library-agnostic. Emits alphabetically-sorted keys (satisfies
stylex/sort-keys with zero autofixes) and refuses conditions/duplicates/
non-static values rather than mis-emitting. styleToObjectAst added to the
rewriter wrapper is emit's AST-rendering bridge, keeping core/emit AST-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…orts, orchestrator

Reads static css={{...}} / css({...}) on host elements into neutral
declarations; rewrites each site to {...stylex.props(styles.key)}; inserts a
single per-file registry above the first converted component; cleans up the
@emotion/react import and @jsxImportSource pragma. Whole-file-or-nothing
(M1 policy): any blocker (pseudo/media, template literal, shorthand overlap,
component css prop, pre-existing stylex) skips the file untouched with reasons.
core/ never imported here — the seam holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CSS treats 'rgb(10, 20, 30)' and 'rgb(10,20,30)' as identical and the StyleX
compiler emits the latter; without this the gate flagged every rgb()/multi-arg
value as a false diff. Comma-whitespace is now canonicalized on both sides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…add 8 fixtures

The two M0-skipped checks (byte-exact match, per-fixture semantic-diff) now run
for real against transformEmotionFile. Skip-fixtures assert the transform
refused loudly and left the file byte-identical. New fixtures: css-call-form,
static-values, two-sites (convert) + skip-{pseudo,template-literal,
shorthand-conflict,component-css,existing-stylex} (refuse).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…red coverage

Replaces the M0 throwing stub with a real StyleX-object -> IR reader (flat
static values + fallback arrays). The harness now round-trips each covered
corpus entry (read -> emit -> compile -> semantic-diff vs original, empty
allowlist) and reports measured coverage; condition/keyframe entries remain
typed, SAFE coverage gaps (2/5 covered at M1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Emotion emits plain CSS (browser resolves by cascade: specificity then source
order); StyleX resolves by fixed priority per condition (imported from
@stylexjs/shared, never reinvented). They disagree only among equal-specificity
conditions where CSS uses source order but StyleX uses priority. For each
property, within each pseudo-element target (different box = no competition),
the cascade order must equal the priority order — else refuse. Prevents the
silent-wrong-cascade bug class (e.g. :focus-then-:hover). Tested against
hand-written IR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
emit groups atoms by property and nests conditions into StyleX's
property-grouped shape (the inverse of Emotion's selector-grouped input),
canonicalizing nesting order and filling default: null where a condition has
no base. Hover-guard (:hover wrapped in @media (hover: hover)) is on by
default, toggleable. buildIR carries conditions + fallback-array values; the
rewriter renders nested condition objects with string-literal keys. Tested
against hand-written IR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
read walks nested condition objects into declarations carrying condition
paths — pseudo-classes, pseudo-elements, media queries and their nesting —
refusing non-self-targeting selectors, functional pseudos, and conditions
nested inside a pseudo-element. transform runs each rule through the referee
(refuse on disagreement) and threads the hoverGuard option into emit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eree & descendant refuse

Renames skip-pseudo -> hover-pseudo (now converts, hover-guarded). Adds
focus-media, pseudo-element (convert) and skip-referee-conflict (:focus before
:hover), skip-descendant-selector (refuse). Each verified across all four
harness checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 5/5

The test-only StyleX-object reader now parses condition-in-value objects
(pseudo/at-rule keys, nested), proving the flip round-trips from the StyleX
side (read -> emit with hoverGuard off -> compile -> semantic-diff, identity).
The whole seed corpus now round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maps inline-axis physical properties to logical (marginLeft->marginInlineStart,
left->insetInlineStart, ...) — a doc-sanctioned RTL behavior change covered by
the semantic-diff physical->logical allowlist and matching StyleX's own
preference. Block-axis (top/bottom) stays physical (no RTL flip). On by
default, { logicalProperties: false } to disable. Tested against hand-written IR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e output

verifyAndFix with all @stylexjs/eslint-plugin rules at error, so key ordering
and shorthands match StyleX's canonical form instead of a heuristic we
maintain (StyleX sort-keys is not alphabetical). Residual unfixable errors ->
caller refuses the file. File-wide is safe now (a converted file has no
pre-existing user stylex.create); M4 adds scoping. The semantic-diff gate runs
on this output, catching any sort-keys reorder that changes rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A property with >=2 sibling at-rule (e.g. @media) conditions sharing a pseudo
context becomes sibling media keys; sort-keys reorders them but the compiler
treats sibling media order as semantic, and the per-coordinate semantic-diff
gate cannot see it. Refuse (never mis-emit) until the upstream inconsistency
is resolved. Verified empirically that verifyAndFix does reorder such keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t) into transform

Threads logicalProperties + hoverGuard options; refuses on postprocess residual
errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dia refuses

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… longhands

Emotion writes 'margin: 8px 16px' as one declaration; the codemod emits
StyleX's expanded logical form (marginBlock/marginInline). Compared by property
name these read as a diff even though they render identically. So before
diffing, both sides' margin/padding shorthands (and their logical variants) are
expanded to the four physical per-side longhands, resolving logical->physical
in LTR. This unblocks multi-value shorthand conversion, which the transform
already performs via the postprocess autofix. inset/border keep the allowlist
path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IR Value gains a 'reference' variant (a bare identifier, e.g. animationName
pointing at a keyframes var). emit renders it as a $$ref sentinel and emits
KeyframesRule -> named frame objects (reusing the property-grouping machinery);
the rewriter renders $$ref as an identifier. normalize maps physical->logical
inside frames too. Tested against hand-written IR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Detects 'const NAME = keyframes({...})' from @emotion/react, reads its frames,
rewrites to stylex.keyframes, and allows 'animationName: NAME' as a preserved
identifier reference in css props. Import/pragma cleanup now strips 'keyframes'
alongside 'css'. Non-object/tagged-template/reassigned keyframes are refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… generated animation-name

The animation-name hash differs between Emotion and StyleX, so the gate skips
@Keyframes rules in the per-property net CSS, extracts their frame contents
separately (keyframesFromStylexMetadata), and the harness compares them as an
order-independent multiset against Emotion's serialized frames. animation-name
present only on the after side is allowlisted (it references converted
keyframes; a literal animationName stays diffed). New fixture: keyframes-spin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records why token->defineVars is deferred to the config milestone: it needs
module-resolution gate config + multi-file fixtures (M6 infra), and a token's
value is external to the file so the semantic-diff cannot verify it (the
project's first trusted transformation). M3 is complete with a/b/c. Also
documents the trusted-transformation boundary as a principle and refreshes the
README converts/refuses table + status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t reservedKeys

postprocess gains an excludeRules option (used to drop no-unused when linting a
standalone snippet whose styles are used elsewhere). emit gains reservedKeys so
generated style-name keys avoid colliding with a pre-existing registry's keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Postprocess now fixes ONLY the emitted stylex (built as a standalone snippet,
fixed, then the fixed objects spliced back), so a user's pre-existing stylex is
never linted or reordered. A pre-existing 'import * as stylex' + stylex.create
is now a MERGE target (detectExistingRegistry): our fixed style entries are
appended to it, keys de-duped, no second registry/import. A final whole-file
lint VERIFY (not fix) refuses a merge whose user code is lint-dirty rather than
silently rewriting it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit tests prove the scoping guarantee: merge appends to a pre-existing
registry leaving the user entry verbatim; a lint-dirty user registry is refused
(never silently reordered); colliding keys get a numeric suffix. skip-existing-
stylex renamed to merge-existing-registry (now converts). The fixture harness's
semantic-diff before-side now also compiles the INPUT's pre-existing stylex, so
merged output verifies correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
core/todos.js owns the TODO marker text, canonical REASONS, and the re-run
marker check. rewriter gains jsxComment(): a braced JSX comment node (parsed so
recast reprints it correctly) — a bare block comment would render as text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…le-file

Each css site now converts or is flagged with a // TODO(stylex-migration): …
marker; the file is no longer skipped because of one unconvertible site.
detectSites classifies each css prop convertible/flagged; readSite and the
per-rule referee flag their own sites. Emotion wiring is kept where still used
(a flagged css prop keeps the pragma; a still-referenced css/keyframes keeps its
import — the reference check excludes member-property/object-key positions). A
re-run guard leaves already-flagged sites alone. Genuinely file-level issues
(non-namespace stylex import, ≥2 registries, unconvertible keyframes) still
refuse the whole file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ahmedsadid and others added 6 commits July 23, 2026 17:03
skip-* fixtures renamed flag-* (they now convert what they can and flag the
rest in place). New: flag-mixed (convert two sites, flag a referee conflict
between them), refuse-non-namespace-stylex (a genuine whole-file refusal).
flagging-test.js proves the re-run guard (no double-flag), whole-file refusal
still fires, and a clean file reports no flags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…refs)

The reason strings land as comments in the user's committed source, so they
must be short, plain, and timeless. Removed the codemod-version/milestone
references ('(v1.1)', 'needs the M2 referee', 'not analyzable yet') and dropped
three unused REASONS entries. Anything shipped-later belongs in the dry-run
report (M6), not in a committed comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
stylex-codemod emotion "<glob>" [--write] [--config] [--verbose] — DRY RUN by
default (previews a convert/refuse/TODO report, writes nothing); --write
applies. config/loadConfig.js loads stylex-codemod.config.js (hoverGuard,
logicalProperties; unknown keys/types throw). cli/run.js is the testable engine
(glob -> transform -> structured report), cli/report.js renders it, cli/index.js
is the yargs bin. Removes the M0 CLI placeholder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tests cover config load/validation, dry-run (writes nothing) vs --write, option
threading (logicalProperties: false), and the report shape. README documents
the CLI + config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A robustness corpus (realistic + awkward inputs) surfaced three real bugs, now
fixed: (1) the compile gate couldn't parse TypeScript (added preset-typescript
for .ts/.tsx) or bare Flow types without a @flow pragma (flow: 'all'); (2) the
TS parser recorded object-literal parens, producing stylex.create(({...})) —
stripped on extraction; (3) removePragma only checked body[0], so a file with
no pre-existing imports kept its @jsxImportSource pragma after full conversion
(now scans all top-level statements). The corpus asserts no-throw, compile+lint-
clean output, and full de-Emotion-ification on flag-free conversions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
'Migrating from Emotion to StyleX' guide: quick start, converts/flags/refuses,
config, design-token guidance, and known limitations (TypeScript best-effort,
styled()/Global out of scope, cross-file flagged). README marks v1.0 MVP
feature-complete and links the guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 23, 2026
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

@ahmedsadid is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@ahmedsadid
ahmedsadid force-pushed the codemod/emotion-m7-harden branch from 1714116 to 4b20293 Compare July 23, 2026 23:47
@ahmedsadid
ahmedsadid marked this pull request as ready for review July 24, 2026 00:35
@ahmedsadid
ahmedsadid requested review from mellyeliu and nmn as code owners July 24, 2026 00:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant