From 3fd5db7498e268221f7baf2de07819801870079f Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Fri, 17 Jul 2026 23:17:43 -0400 Subject: [PATCH 01/32] codemods: scaffold @stylexjs/codemods package and docs skeleton Co-Authored-By: Claude Fable 5 --- .eslintrc.js | 3 + .flowconfig | 1 + packages/@stylexjs/codemods/.babelrc.js | 17 + packages/@stylexjs/codemods/ARCHITECTURE.md | 100 ++++++ packages/@stylexjs/codemods/README.md | 51 +++ packages/@stylexjs/codemods/package.json | 38 +++ .../codemods/src/adapters/emotion/README.md | 9 + packages/@stylexjs/codemods/src/cli/README.md | 5 + packages/@stylexjs/codemods/src/index.js | 37 +++ yarn.lock | 303 +++++++++++++++++- 10 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 packages/@stylexjs/codemods/.babelrc.js create mode 100644 packages/@stylexjs/codemods/ARCHITECTURE.md create mode 100644 packages/@stylexjs/codemods/README.md create mode 100644 packages/@stylexjs/codemods/package.json create mode 100644 packages/@stylexjs/codemods/src/adapters/emotion/README.md create mode 100644 packages/@stylexjs/codemods/src/cli/README.md create mode 100644 packages/@stylexjs/codemods/src/index.js diff --git a/.eslintrc.js b/.eslintrc.js index 0ba078fed..49b10c5f1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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: [ { diff --git a/.flowconfig b/.flowconfig index e74e90e47..4271b77a6 100644 --- a/.flowconfig +++ b/.flowconfig @@ -24,6 +24,7 @@ module.name_mapper='^@stylexjs/babel-plugin$' -> '/packages/@style module.name_mapper='^@stylexjs/stylex$' -> '/packages/@stylexjs/stylex/src/stylex.js' module.name_mapper='^@stylexjs/shared$' -> '/packages/@stylexjs/shared/src/index.js' module.name_mapper='^style-value-parser$' -> '/packages/style-value-parser/src/index.js' +module.name_mapper='^@stylexjs/eslint-plugin$' -> '/packages/@stylexjs/eslint-plugin/src/index.js' ; type-stubs module.system.node.resolve_dirname=flow_modules module.system.node.resolve_dirname=node_modules diff --git a/packages/@stylexjs/codemods/.babelrc.js b/packages/@stylexjs/codemods/.babelrc.js new file mode 100644 index 000000000..5863e3f85 --- /dev/null +++ b/packages/@stylexjs/codemods/.babelrc.js @@ -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' }]], +}; diff --git a/packages/@stylexjs/codemods/ARCHITECTURE.md b/packages/@stylexjs/codemods/ARCHITECTURE.md new file mode 100644 index 000000000..227309481 --- /dev/null +++ b/packages/@stylexjs/codemods/ARCHITECTURE.md @@ -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//{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) | diff --git a/packages/@stylexjs/codemods/README.md b/packages/@stylexjs/codemods/README.md new file mode 100644 index 000000000..77dc801bf --- /dev/null +++ b/packages/@stylexjs/codemods/README.md @@ -0,0 +1,51 @@ +# @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: pre-release scaffold (M0).** The correctness harness — fixture +> tests plus three gates (compile, lint, semantic-diff) — is in place and +> proven; transforms land milestone by milestone. Nothing here is published +> yet, and the package name/location is pending maintainer confirmation. + +## 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({})` / object-form `styled.div({})`; self-targeting pseudo-classes/elements; media queries; object-form `keyframes`; fallback arrays; shorthands; mapped theme tokens | +| Flags | template literals; dynamic styles; `styled(Component)`; out-of-element selectors; ``; `shouldForwardProp`; unmapped tokens; `!important` | +| Refuses | any file where partial conversion could change rendering | + +## Compatibility + +| | Version | +| --- | --- | +| Emits StyleX | 0.19.x | +| Reads Emotion | 10–11 (object syntax) | + +See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the design. + +## 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//{input,expected}.js`; set +`UPDATE_STYLEX_CODEMOD_FIXTURES=1` to regenerate expected files when a +change is intentional. diff --git a/packages/@stylexjs/codemods/package.json b/packages/@stylexjs/codemods/package.json new file mode 100644 index 000000000..dec715445 --- /dev/null +++ b/packages/@stylexjs/codemods/package.json @@ -0,0 +1,38 @@ +{ + "name": "@stylexjs/codemods", + "version": "0.19.0", + "description": "Codemods for migrating styling libraries (Emotion first) to StyleX", + "main": "./lib/index.js", + "repository": "https://www.github.com/facebook/stylex", + "license": "MIT", + "private": true, + "scripts": { + "prebuild": "gen-types -i src/ -o lib/", + "build": "cross-env BABEL_ENV=cjs babel src/ --out-dir lib/ --copy-files", + "test": "jest" + }, + "dependencies": { + "@babel/core": "^7.28.5", + "@stylexjs/babel-plugin": "0.19.0", + "@stylexjs/eslint-plugin": "0.19.0", + "@stylexjs/shared": "0.19.0", + "babel-plugin-syntax-hermes-parser": "0.36.1", + "eslint": "8.57.1", + "hermes-eslint": "0.36.1", + "jscodeshift": "^17.3.0", + "style-value-parser": "0.19.0" + }, + "devDependencies": { + "@emotion/serialize": "^1.3.3", + "scripts": "0.19.0" + }, + "jest": { + "testPathIgnorePatterns": [ + "/__fixtures__/", + "/__tests__/utils/" + ] + }, + "files": [ + "lib/*" + ] +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/README.md b/packages/@stylexjs/codemods/src/adapters/emotion/README.md new file mode 100644 index 000000000..c6a726039 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/README.md @@ -0,0 +1,9 @@ +# Emotion adapter (reader) + +Lands from **M1**. This directory will hold the library-specific side of the +seam: `detect.js` (find Emotion style sites), `read.js` (style sites -> +neutral `declarations`), `rewriteSites.js` (binding map -> `stylex.props` +call sites), and `imports.js` (Emotion import/pragma cleanup). + +Invariant: `src/core/` never imports from this directory. The seam is +enforced by `__tests__/seam-test.js`. diff --git a/packages/@stylexjs/codemods/src/cli/README.md b/packages/@stylexjs/codemods/src/cli/README.md new file mode 100644 index 000000000..c04d3f01c --- /dev/null +++ b/packages/@stylexjs/codemods/src/cli/README.md @@ -0,0 +1,5 @@ +# CLI + +Lands in **M6**: `stylex-codemod emotion "" [--dry-run]` (dry-run is +the default), config loading (`resolveValue` glossary, `hoverGuard`, +logical-properties opt-out), and the convert/flag/refuse report. diff --git a/packages/@stylexjs/codemods/src/index.js b/packages/@stylexjs/codemods/src/index.js new file mode 100644 index 000000000..e454775db --- /dev/null +++ b/packages/@stylexjs/codemods/src/index.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * @stylexjs/codemods — migrate styling libraries to StyleX. + * + * M0: gated harness only. The public surface is the IR types and the three + * correctness gates; transforms land from M1. + */ + +export type { + Atom, + Condition, + Value, + StyleRule, + KeyframesRule, + FileIR, +} from './core/ir'; +export { conditionKey, atomCoordinate } from './core/ir'; + +export { compileGate } from './core/gates/compile'; +export { lintGate } from './core/gates/lint'; +export { + semanticDiffGate, + netCssFromSerializedCss, + netCssFromStylexMetadata, + DEFAULT_ALLOWLIST, + UnsupportedCssError, +} from './core/gates/semanticDiff'; + +export { parseSource, printSource, parserForFile } from './core/rewriter'; diff --git a/yarn.lock b/yarn.lock index 5936037d6..32d6175b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -243,6 +243,11 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz" integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== +"@babel/compat-data@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" + integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== + "@babel/core@^7.18.2", "@babel/core@^7.23.9", "@babel/core@^7.24.4", "@babel/core@^7.26.8", "@babel/core@^7.27.4", "@babel/core@^7.28.0", "@babel/core@^7.28.4", "@babel/core@^7.28.5": version "7.28.5" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz" @@ -264,6 +269,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/eslint-parser@^7.26.8": version "7.28.5" resolved "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.5.tgz" @@ -302,6 +328,13 @@ dependencies: "@babel/types" "^7.27.3" +"@babel/helper-annotate-as-pure@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz#c70fe3c6ecbdc3fd2dd1b0f498428b88b82ce47f" + integrity sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw== + dependencies: + "@babel/types" "^7.29.7" + "@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2": version "7.27.2" resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz" @@ -313,6 +346,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" + integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== + dependencies: + "@babel/compat-data" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.27.1", "@babel/helper-create-class-features-plugin@^7.28.3", "@babel/helper-create-class-features-plugin@^7.28.5": version "7.28.5" resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz" @@ -326,6 +370,19 @@ "@babel/traverse" "^7.28.5" semver "^6.3.1" +"@babel/helper-create-class-features-plugin@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz#6eddf286f2ec418f740c91d60a83347c55838ddd" + integrity sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.29.7" + "@babel/helper-member-expression-to-functions" "^7.29.7" + "@babel/helper-optimise-call-expression" "^7.29.7" + "@babel/helper-replace-supers" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" + "@babel/traverse" "^7.29.7" + semver "^6.3.1" + "@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.27.1": version "7.28.5" resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz" @@ -364,6 +421,14 @@ "@babel/traverse" "^7.28.5" "@babel/types" "^7.28.5" +"@babel/helper-member-expression-to-functions@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz#8dbdb3ce0b5c487e1aec10e13c9a43a500814df8" + integrity sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/helper-module-imports@^7.18.6", "@babel/helper-module-imports@^7.25.9", "@babel/helper-module-imports@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz" @@ -405,6 +470,13 @@ dependencies: "@babel/types" "^7.27.1" +"@babel/helper-optimise-call-expression@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz#77b0b5b94f1997fa9d6e3125f445227b1faf9d85" + integrity sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong== + dependencies: + "@babel/types" "^7.29.7" + "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz" @@ -433,6 +505,15 @@ "@babel/helper-optimise-call-expression" "^7.27.1" "@babel/traverse" "^7.27.1" +"@babel/helper-replace-supers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz#bc3c3964329043c79112e513c1b198f16589ac21" + integrity sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.29.7" + "@babel/helper-optimise-call-expression" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz" @@ -441,6 +522,14 @@ "@babel/traverse" "^7.27.1" "@babel/types" "^7.27.1" +"@babel/helper-skip-transparent-expression-wrappers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz#50c95c7e4c4f54936cfa0116428edc559862d551" + integrity sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/helper-string-parser@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" @@ -466,6 +555,11 @@ resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz" integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== +"@babel/helper-validator-option@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" + integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== + "@babel/helper-wrap-function@^7.27.1": version "7.28.3" resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz" @@ -483,6 +577,14 @@ "@babel/template" "^7.27.2" "@babel/types" "^7.28.4" +"@babel/helpers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== + dependencies: + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/highlight@^7.16.7": version "7.25.9" resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz" @@ -512,7 +614,7 @@ dependencies: "@babel/types" "^7.29.0" -"@babel/parser@^7.29.7": +"@babel/parser@^7.24.7", "@babel/parser@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== @@ -598,6 +700,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-flow@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz#3f3278c11c896c43bf8098250819785fd1231881" + integrity sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-import-assertions@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz" @@ -633,6 +742,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-jsx@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz#622c16f9ad63782fe6e83dadc7e40330744b7f1e" + integrity sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz" @@ -696,6 +812,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-syntax-typescript@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz#7c29388932313ed58413a0343048d75d92fb5b24" + integrity sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-unicode-sets-regex@^7.18.6": version "7.18.6" resolved "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz" @@ -743,6 +866,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-class-properties@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz#034897b8a21beec163332fac2de235b14409abdf" + integrity sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-class-properties@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz" @@ -847,6 +978,14 @@ "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-syntax-flow" "^7.27.1" +"@babel/plugin-transform-flow-strip-types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz#911bcb31608c3576510d7e0c95ccf64f9e1812d0" + integrity sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-syntax-flow" "^7.29.7" + "@babel/plugin-transform-for-of@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz" @@ -900,6 +1039,14 @@ "@babel/helper-module-transforms" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-modules-commonjs@^7.24.7", "@babel/plugin-transform-modules-commonjs@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz#70e6835abf2663dafbe94b8ef1f51de7351ef135" + integrity sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ== + dependencies: + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-modules-commonjs@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz" @@ -941,6 +1088,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz#8a54cdf88c3f50433a6173117a286195b67714cc" + integrity sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-nullish-coalescing-operator@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz" @@ -981,6 +1135,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-optional-chaining@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz#b84a1b574b3c73001023092567e16c492b720e51" + integrity sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" + "@babel/plugin-transform-optional-chaining@^7.27.1", "@babel/plugin-transform-optional-chaining@^7.28.5": version "7.28.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz" @@ -996,6 +1158,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-private-methods@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz#cea8bd3ab99533892897a02999d5b752584ad145" + integrity sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/plugin-transform-private-methods@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz" @@ -1136,6 +1306,17 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.27.1" +"@babel/plugin-transform-typescript@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz#f0449c3df7037bbe232043476851c38f5e4a7615" + integrity sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.29.7" + "@babel/helper-create-class-features-plugin" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" + "@babel/plugin-syntax-typescript" "^7.29.7" + "@babel/plugin-transform-unicode-escapes@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz" @@ -1243,6 +1424,15 @@ core-js-compat "^3.43.0" semver "^6.3.1" +"@babel/preset-flow@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/preset-flow/-/preset-flow-7.29.7.tgz#ae33812dc267a296bd3709843affbb2d811c4709" + integrity sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + "@babel/plugin-transform-flow-strip-types" "^7.29.7" + "@babel/preset-flow@^7.25.9", "@babel/preset-flow@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz" @@ -1273,6 +1463,17 @@ "@babel/plugin-transform-react-jsx-development" "^7.27.1" "@babel/plugin-transform-react-pure-annotations" "^7.27.1" +"@babel/preset-typescript@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz#de9be1f47b785c979ec7b3a71f4cd8bae5267b62" + integrity sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + "@babel/plugin-syntax-jsx" "^7.29.7" + "@babel/plugin-transform-modules-commonjs" "^7.29.7" + "@babel/plugin-transform-typescript" "^7.29.7" + "@babel/preset-typescript@^7.25.7", "@babel/preset-typescript@^7.26.0": version "7.28.5" resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz" @@ -1284,6 +1485,17 @@ "@babel/plugin-transform-modules-commonjs" "^7.27.1" "@babel/plugin-transform-typescript" "^7.28.5" +"@babel/register@^7.24.6": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.29.7.tgz#d5bb4337065512f643b29cb565b6e8ccadcc1ec6" + integrity sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA== + dependencies: + clone-deep "^4.0.1" + find-cache-dir "^2.0.0" + make-dir "^2.1.0" + pirates "^4.0.6" + source-map-support "^0.5.16" + "@babel/register@^7.27.1": version "7.28.3" resolved "https://registry.npmjs.org/@babel/register/-/register-7.28.3.tgz" @@ -1707,6 +1919,37 @@ dependencies: tslib "^2.4.0" +"@emotion/hash@^0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b" + integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== + +"@emotion/memoize@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.9.0.tgz#745969d649977776b43fc7648c556aaa462b4102" + integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== + +"@emotion/serialize@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.3.3.tgz#d291531005f17d704d0463a032fe679f376509e8" + integrity sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA== + dependencies: + "@emotion/hash" "^0.9.2" + "@emotion/memoize" "^0.9.0" + "@emotion/unitless" "^0.10.0" + "@emotion/utils" "^1.4.2" + csstype "^3.0.2" + +"@emotion/unitless@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.10.0.tgz#2af2f7c7e5150f497bdabd848ce7b218a27cf745" + integrity sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg== + +"@emotion/utils@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.4.2.tgz#6df6c45881fcb1c412d6688a311a98b7f59c1b52" + integrity sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA== + "@epic-web/invariant@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz" @@ -6779,7 +7022,7 @@ babel-plugin-react-compiler@1.0.0, babel-plugin-react-compiler@^1.0.0: dependencies: "@babel/types" "^7.26.0" -babel-plugin-syntax-hermes-parser@^0.36.1: +babel-plugin-syntax-hermes-parser@0.36.1, babel-plugin-syntax-hermes-parser@^0.36.1: version "0.36.1" resolved "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz" integrity sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA== @@ -9652,6 +9895,18 @@ flow-enums-runtime@^0.0.6: resolved "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz" integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw== +flow-estree@0.322.0: + version "0.322.0" + resolved "https://registry.yarnpkg.com/flow-estree/-/flow-estree-0.322.0.tgz#8310b5bc825049fe6526969217ee29bb09fdd576" + integrity sha512-v7wiiFWSKbJ1HGVCRKhxl+6pQWTQV6P4c7XEfKjVlH71YkgQyJCq0AG5dhJDD/3/SkJgX/F9lvAj2rbrFzArdQ== + +flow-parser@0.*: + version "0.322.0" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.322.0.tgz#7a42cfae08b0cf0a35bd9fdbe84593782f6a5052" + integrity sha512-Qfd8N4sSuWmlf1qk5M60fi8JrvhNvj9fbwMsRkcnm3UhLYukkbm2CFkAhiTD3n4RVXb6TuCHFCp78TMXpO0zcA== + dependencies: + flow-estree "0.322.0" + flow-typed@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/flow-typed/-/flow-typed-4.1.1.tgz" @@ -11542,6 +11797,30 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jscodeshift@^17.3.0: + version "17.4.0" + resolved "https://registry.yarnpkg.com/jscodeshift/-/jscodeshift-17.4.0.tgz#efa0619ddb1d994e85982ec37a7afe8e618ae324" + integrity sha512-i3ESKiiTsGynxzTg5BhsZViD0ai72/6SsI1efDZxG6/5KCoElsmquxtyhXK5lpEgoO7MTNpYkjFEdEL97SkBNg== + dependencies: + "@babel/core" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/plugin-transform-class-properties" "^7.24.7" + "@babel/plugin-transform-modules-commonjs" "^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7" + "@babel/plugin-transform-optional-chaining" "^7.24.7" + "@babel/plugin-transform-private-methods" "^7.24.7" + "@babel/preset-flow" "^7.24.7" + "@babel/preset-typescript" "^7.24.7" + "@babel/register" "^7.24.6" + flow-parser "0.*" + graceful-fs "^4.2.4" + neo-async "^2.5.0" + picocolors "^1.0.1" + picomatch "^4.0.2" + recast "^0.23.11" + tmp "^0.2.3" + write-file-atomic "^5.0.1" + jsdom@^26.1.0: version "26.1.0" resolved "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz" @@ -13056,7 +13335,7 @@ negotiator@~0.6.4: resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== -neo-async@^2.6.1, neo-async@^2.6.2: +neo-async@^2.5.0, neo-async@^2.6.1, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -13700,7 +13979,7 @@ pend@~1.2.0: resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== -picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1, picocolors@~1.1.1: +picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1, picocolors@~1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -14322,6 +14601,17 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +recast@^0.23.11: + version "0.23.12" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.12.tgz#3df7589ae1877d0a634c6c7ccc995a7c053850a9" + integrity sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA== + dependencies: + ast-types "^0.16.1" + esprima "~4.0.0" + source-map "~0.6.1" + tiny-invariant "^1.3.3" + tslib "^2.0.1" + recast@^0.23.5: version "0.23.11" resolved "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz" @@ -16183,6 +16473,11 @@ tmp@^0.2.0: resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz" integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== +tmp@^0.2.3: + version "0.2.7" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059" + integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz" From ab45fb8c7bf4475d2986b59cb37c7c910768d03e Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Fri, 17 Jul 2026 23:19:08 -0400 Subject: [PATCH 02/32] codemods: IR types, rewriter wrapper, seam enforcement tests Co-Authored-By: Claude Fable 5 --- .../@stylexjs/codemods/__tests__/ir-test.js | 35 ++++++ .../codemods/__tests__/rewriter-test.js | 35 ++++++ .../@stylexjs/codemods/__tests__/seam-test.js | 78 +++++++++++++ packages/@stylexjs/codemods/src/core/ir.js | 108 ++++++++++++++++++ .../@stylexjs/codemods/src/core/rewriter.js | 48 ++++++++ 5 files changed, 304 insertions(+) create mode 100644 packages/@stylexjs/codemods/__tests__/ir-test.js create mode 100644 packages/@stylexjs/codemods/__tests__/rewriter-test.js create mode 100644 packages/@stylexjs/codemods/__tests__/seam-test.js create mode 100644 packages/@stylexjs/codemods/src/core/ir.js create mode 100644 packages/@stylexjs/codemods/src/core/rewriter.js diff --git a/packages/@stylexjs/codemods/__tests__/ir-test.js b/packages/@stylexjs/codemods/__tests__/ir-test.js new file mode 100644 index 000000000..5c9f3a439 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/ir-test.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type { Condition } from '../src/core/ir'; +import { conditionKey, atomCoordinate } from '../src/core/ir'; + +test('conditionKey covers every condition kind', () => { + expect(conditionKey({ kind: 'pseudo-class', name: ':hover' })).toEqual( + ':hover', + ); + expect(conditionKey({ kind: 'pseudo-element', name: '::before' })).toEqual( + '::before', + ); + expect( + conditionKey({ kind: 'at-rule', rule: '@media (min-width: 600px)' }), + ).toEqual('@media (min-width: 600px)'); +}); + +test('atomCoordinate is stable under condition ordering', () => { + const hover: Condition = { kind: 'pseudo-class', name: ':hover' }; + const media: Condition = { + kind: 'at-rule', + rule: '@media (min-width: 600px)', + }; + expect(atomCoordinate('color', [hover, media])).toEqual( + atomCoordinate('color', [media, hover]), + ); + expect(atomCoordinate('color', [])).toEqual('color'); +}); diff --git a/packages/@stylexjs/codemods/__tests__/rewriter-test.js b/packages/@stylexjs/codemods/__tests__/rewriter-test.js new file mode 100644 index 000000000..9ce8ea0fb --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/rewriter-test.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import { parseSource, printSource, parserForFile } from '../src/core/rewriter'; + +test('parse -> print with no mutation preserves the source byte-exactly', () => { + const source = [ + '/** @jsxImportSource @emotion/react */', + "import * as React from 'react';", + '', + 'export default function Badge() {', + " return
Badge
;", + '}', + '', + ].join('\n'); + expect(printSource(parseSource(source))).toEqual(source); +}); + +test('parses Flow-typed user code', () => { + const source = 'const x: number = 1;\n'; + expect(printSource(parseSource(source))).toEqual(source); +}); + +test('picks the TS parser for TypeScript files', () => { + expect(parserForFile('Component.tsx')).toEqual('tsx'); + expect(parserForFile('Component.ts')).toEqual('tsx'); + expect(parserForFile('Component.js')).toEqual('flow'); + expect(parserForFile('Component.jsx')).toEqual('flow'); +}); diff --git a/packages/@stylexjs/codemods/__tests__/seam-test.js b/packages/@stylexjs/codemods/__tests__/seam-test.js new file mode 100644 index 000000000..7b766f914 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/seam-test.js @@ -0,0 +1,78 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Enforces the load-bearing architectural invariants from commit one: + * + * 1. `src/core/` never imports from `src/adapters/` — the neutral-IR seam. + * The day core needs an adapter, the seam has leaked; that is a + * regression, not a refactor. + * 2. Only `src/core/rewriter.js` may import the AST toolkit (jscodeshift), + * so the toolkit stays swappable behind one wrapper. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const SRC = path.join(__dirname, '..', 'src'); + +function jsFilesUnder(dir: string): Array { + const out: Array = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + // String(): Flow's Dirent libdef types `name` as string | Buffer. + const name = String(entry.name); + const full = path.join(dir, name); + if (entry.isDirectory()) { + out.push(...jsFilesUnder(full)); + } else if (name.endsWith('.js')) { + out.push(full); + } + } + return out; +} + +function importSpecifiers(source: string): Array { + const specifiers = []; + const pattern = /(?:from\s+|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g; + for (;;) { + const match = pattern.exec(source); + if (match == null) { + break; + } + specifiers.push(match[1]); + } + return specifiers; +} + +test('src/core/ never imports from src/adapters/', () => { + for (const file of jsFilesUnder(path.join(SRC, 'core'))) { + for (const spec of importSpecifiers(fs.readFileSync(file, 'utf8'))) { + expect({ file: path.relative(SRC, file), imports: spec }).not.toEqual( + expect.objectContaining({ + imports: expect.stringMatching(/adapters/), + }), + ); + } + } +}); + +test('only core/rewriter.js imports the AST toolkit', () => { + for (const file of jsFilesUnder(SRC)) { + if (path.relative(SRC, file) === path.join('core', 'rewriter.js')) { + continue; + } + for (const spec of importSpecifiers(fs.readFileSync(file, 'utf8'))) { + expect({ file: path.relative(SRC, file), imports: spec }).not.toEqual( + expect.objectContaining({ + imports: expect.stringMatching(/^(jscodeshift|recast)/), + }), + ); + } + } +}); diff --git a/packages/@stylexjs/codemods/src/core/ir.js b/packages/@stylexjs/codemods/src/core/ir.js new file mode 100644 index 000000000..d1bedb2d4 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/ir.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * The neutral intermediate representation (IR) shared by every adapter and + * the core engine. Its shape mirrors StyleX's OUTPUT (property-grouped, with + * conditions nested inside a property), not any source library's input. + * + * A style either maps into this IR (=> convertible) or it does not + * (=> flagged, never guessed). The edge of the IR is the edge of what is + * automatable. + * + * The two open axes — `Condition` and `Value` — are variant types because + * they are the only axes StyleX itself grows along. New condition kinds + * (`@supports`, `@container`, ancestor selectors) and new value kinds + * (dynamic/function-form) are ADDITIVE extensions; the structure never + * changes for a new source library. + */ + +/** + * A single typed condition under which a value applies. + * Only self-targeting conditions are representable — selectors that reach + * outside the element have no encoding here, by design. + */ +export type Condition = + | { +kind: 'pseudo-class', +name: string } // e.g. ':hover' + | { +kind: 'pseudo-element', +name: string } // e.g. '::before' + | { +kind: 'at-rule', +rule: string }; // e.g. '@media (min-width: 600px)' + +/** + * The value of an atom. `static` is the only variant in v1.0; a + * `dynamic` variant (props-driven, -> StyleX function-form create) is the + * planned v1.1 addition. + */ +export type Value = + | { +kind: 'static', +value: string | number | null } + | { + +kind: 'first-that-works', + +values: $ReadOnlyArray, + }; + +/** + * The unit of the IR: one property, under zero or more conditions, + * with one value. e.g. { property: 'color', conditions: [:hover], 'blue' }. + */ +export type Atom = { + +property: string, // camelCase, StyleX-style (e.g. 'backgroundColor') + +conditions: $ReadOnlyArray, + +value: Value, +}; + +/** + * One named entry in a `stylex.create` registry — a group of atoms with the + * key the emitter should try to use for it. + */ +export type StyleRule = { + +name: string, + +atoms: $ReadOnlyArray, +}; + +/** An object-form keyframes declaration (`stylex.keyframes`). */ +export type KeyframesRule = { + +name: string, + +frames: $ReadOnlyArray<{ + +selector: string, // 'from' | 'to' | '0%' ... + +atoms: $ReadOnlyArray, + }>, +}; + +/** + * Everything the engine knows about one file's styles: the rules destined + * for the single per-file `stylex.create`, plus file-level constructs. + * Design tokens (`defineVars`) join in M3. + */ +export type FileIR = { + +rules: $ReadOnlyArray, + +keyframes: $ReadOnlyArray, +}; + +/** Stable string form of a condition, used for grouping and diffing. */ +export function conditionKey(condition: Condition): string { + switch (condition.kind) { + case 'pseudo-class': + case 'pseudo-element': + return condition.name; + case 'at-rule': + default: + return condition.rule; + } +} + +/** + * Stable string form of an atom's (property, conditions) coordinate — + * conditions sorted so ordering differences do not create false diffs. + */ +export function atomCoordinate( + property: string, + conditions: $ReadOnlyArray, +): string { + const keys = conditions.map(conditionKey).slice().sort(); + return keys.length === 0 ? property : `${property} @ ${keys.join(' && ')}`; +} diff --git a/packages/@stylexjs/codemods/src/core/rewriter.js b/packages/@stylexjs/codemods/src/core/rewriter.js new file mode 100644 index 000000000..74bc8135c --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/rewriter.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * The single place in the codebase that knows which AST toolkit we use. + * Everything else (core and adapters alike) goes through this wrapper, so + * jscodeshift/recast stays swappable (hermes-parser, ts-morph, plain + * babel+recast) without touching any other module — a maintainer question + * we have deliberately kept open. + * + * jscodeshift is the default: format-preserving printing via recast, and + * parses the Flow/TS/JSX found in user code. + */ + +import jscodeshift from 'jscodeshift'; + +// 'flow' also covers plain JS + JSX; 'tsx' also covers plain TS. +export type ParserChoice = 'flow' | 'tsx'; + +export opaque type Rewriter = { + +j: $FlowFixMe, + +root: $FlowFixMe, +}; + +/** Picks a parser from the file extension (TS/TSX vs Flow/JS). */ +export function parserForFile(filename: string): ParserChoice { + return /\.(ts|tsx|mts|cts)$/.test(filename) ? 'tsx' : 'flow'; +} + +/** Parses source into a rewriter handle (a jscodeshift Collection). */ +export function parseSource( + source: string, + options?: { +parser?: ParserChoice }, +): Rewriter { + const j = jscodeshift.withParser(options?.parser ?? 'flow'); + return { j, root: j(source) }; +} + +/** Prints a rewriter handle back to source, format-preserving. */ +export function printSource(rewriter: Rewriter): string { + return rewriter.root.toSource({ quote: 'single' }); +} From 0c52660aa840d4d45c8bb4ae9fbe2cbccabc2496 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Fri, 17 Jul 2026 23:19:12 -0400 Subject: [PATCH 03/32] codemods: compile/lint/semantic-diff gates + fixture harness, each gate proven to fail on a broken pair Co-Authored-By: Claude Fable 5 --- .../__fixtures__/broken/compile-invalid.js | 12 + .../__fixtures__/broken/lint-invalid.js | 12 + .../emotion/static-flat-color/expected.js | 10 + .../emotion/static-flat-color/input.js | 6 + .../__tests__/emotion-fixtures-test.js | 103 +++++ .../codemods/__tests__/gates-test.js | 174 ++++++++ .../codemods/__tests__/utils/harness.js | 76 ++++ .../@emotion/serialize/index.js.flow | 17 + .../flow_modules/eslint/index.js.flow | 37 ++ .../flow_modules/hermes-eslint/index.js.flow | 20 + .../codemods/src/core/gates/compile.js | 49 +++ .../@stylexjs/codemods/src/core/gates/lint.js | 81 ++++ .../codemods/src/core/gates/semanticDiff.js | 376 ++++++++++++++++++ 13 files changed, 973 insertions(+) create mode 100644 packages/@stylexjs/codemods/__fixtures__/broken/compile-invalid.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/input.js create mode 100644 packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js create mode 100644 packages/@stylexjs/codemods/__tests__/gates-test.js create mode 100644 packages/@stylexjs/codemods/__tests__/utils/harness.js create mode 100644 packages/@stylexjs/codemods/flow_modules/@emotion/serialize/index.js.flow create mode 100644 packages/@stylexjs/codemods/flow_modules/eslint/index.js.flow create mode 100644 packages/@stylexjs/codemods/flow_modules/hermes-eslint/index.js.flow create mode 100644 packages/@stylexjs/codemods/src/core/gates/compile.js create mode 100644 packages/@stylexjs/codemods/src/core/gates/lint.js create mode 100644 packages/@stylexjs/codemods/src/core/gates/semanticDiff.js diff --git a/packages/@stylexjs/codemods/__fixtures__/broken/compile-invalid.js b/packages/@stylexjs/codemods/__fixtures__/broken/compile-invalid.js new file mode 100644 index 000000000..b9ab9f903 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/broken/compile-invalid.js @@ -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; diff --git a/packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js b/packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js new file mode 100644 index 000000000..a7f9654c2 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js @@ -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; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js new file mode 100644 index 000000000..1b8c3d22f --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js @@ -0,0 +1,10 @@ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + badge: { color: 'red' }, +}); + +export default function Badge() { + return
Badge
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/input.js new file mode 100644 index 000000000..a57db9ccc --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Badge() { + return
Badge
; +} diff --git a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js new file mode 100644 index 000000000..a25d09fad --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * The fixture harness — the executable spec. Every input/expected pair + * under `__fixtures__/emotion/` must satisfy four checks: + * + * 1. transform(input) matches expected byte-exactly (after Prettier); + * 2. expected compiles through @stylexjs/babel-plugin; + * 3. expected passes @stylexjs/eslint-plugin at error, zero messages; + * 4. the net CSS of input and expected is semantically identical + * (minus the sanctioned allowlist). + * + * Set UPDATE_STYLEX_CODEMOD_FIXTURES=1 to regenerate expected files when a + * change is intentional. + * + * M0 status: no transform exists yet, so checks 1 and 4 (which need the + * transform / the adapter's reader) are pending and explicitly skipped; + * checks 2 and 3 run for real. M1 wires `transform` below and un-skips. + */ + +import * as fs from 'fs'; +import { compileGate } from '../src/core/gates/compile'; +import { lintGate } from '../src/core/gates/lint'; +import { loadFixtures, formatWithPrettier } from './utils/harness'; + +// M1: replace with the real emotion transform (input source -> output source). +const transform: ((source: string, filename: string) => string) | null = null; + +const UPDATE = process.env.UPDATE_STYLEX_CODEMOD_FIXTURES === '1'; + +const fixtures = loadFixtures('emotion'); + +test('there is at least one fixture pair', () => { + expect(fixtures.length).toBeGreaterThan(0); +}); + +test('prettier normalization is available and idempotent', () => { + const [fixture] = fixtures; + const once = formatWithPrettier(fixture.expected, fixture.expectedPath); + expect(formatWithPrettier(once, fixture.expectedPath)).toEqual(once); +}); + +describe.each(fixtures.map((f) => [f.name, f]))( + 'fixture: %s', + (_name, fixture) => { + const testIfTransform = transform == null ? test.skip : test; + + // Check 1 — pending M1 (needs the transform). + testIfTransform('transform(input) matches expected byte-exactly', () => { + if (transform == null) { + throw new Error('unreachable'); + } + const actual = formatWithPrettier( + transform(fixture.input, fixture.inputPath), + fixture.expectedPath, + ); + if (UPDATE) { + fs.writeFileSync(fixture.expectedPath, actual); + } + expect(actual).toEqual( + formatWithPrettier(fixture.expected, fixture.expectedPath), + ); + }); + + // Check 2 — live from M0. + test('expected compiles through @stylexjs/babel-plugin', () => { + const result = compileGate(fixture.expected, { + filename: fixture.expectedPath, + }); + if (!result.ok) { + throw new Error(result.errors.join('\n')); + } + expect(result.ok).toBe(true); + }); + + // Check 3 — live from M0. + test('expected passes @stylexjs/eslint-plugin at error with zero messages', () => { + const result = lintGate(fixture.expected, { + filename: fixture.expectedPath, + }); + if (!result.ok) { + throw new Error(JSON.stringify(result.messages, null, 2)); + } + expect(result.ok).toBe(true); + }); + + // Check 4 — pending M1 (extracting the input's style objects is the + // adapter reader's job; the gate itself is proven in gates-test.js). + testIfTransform( + 'net CSS of input and expected is semantically identical', + () => { + throw new Error('wired in M1 with the emotion adapter reader'); + }, + ); + }, +); diff --git a/packages/@stylexjs/codemods/__tests__/gates-test.js b/packages/@stylexjs/codemods/__tests__/gates-test.js new file mode 100644 index 000000000..dc0efe1f4 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/gates-test.js @@ -0,0 +1,174 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Gate self-tests. A gate that never fails is worthless, so every gate is + * proven BOTH ways: green on the trivial hand-written pair, and red on a + * deliberately-broken input. + */ + +import { serializeStyles } from '@emotion/serialize'; +import { compileGate } from '../src/core/gates/compile'; +import { lintGate } from '../src/core/gates/lint'; +import { + semanticDiffGate, + netCssFromSerializedCss, + netCssFromStylexMetadata, + UnsupportedCssError, +} from '../src/core/gates/semanticDiff'; +import { loadFixtures, readBrokenFixture } from './utils/harness'; + +const [trivialPair] = loadFixtures('emotion').filter( + (f) => f.name === 'static-flat-color', +); + +// The style object literally present in the trivial pair's input.js. +const trivialEmotionObject = { color: 'red' }; + +function emotionNetCss(styleObject: mixed) { + return netCssFromSerializedCss(serializeStyles([styleObject]).styles); +} + +describe('compile gate', () => { + test('passes on the trivial expected output', () => { + const result = compileGate(trivialPair.expected, { + filename: trivialPair.expectedPath, + }); + expect(result.ok).toBe(true); + }); + + test('FAILS on the deliberately-broken pair (non-static create arg)', () => { + const result = compileGate(readBrokenFixture('compile-invalid.js')); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.join('\n')).toMatch(/create\(\)/); + } + }); +}); + +describe('lint gate', () => { + test('passes on the trivial expected output', () => { + const result = lintGate(trivialPair.expected, { + filename: trivialPair.expectedPath, + }); + expect(result).toEqual({ ok: true }); + }); + + test('FAILS on the deliberately-broken pair (invalid property, unused styles)', () => { + const result = lintGate(readBrokenFixture('lint-invalid.js')); + expect(result.ok).toBe(false); + if (!result.ok) { + const ruleIds = result.messages.map((m) => m.ruleId); + expect(ruleIds).toContain('@stylexjs/valid-styles'); + expect(ruleIds).toContain('@stylexjs/no-unused'); + } + }); +}); + +describe('semantic-diff gate', () => { + test('trivial pair end-to-end: Emotion input and compiled StyleX output have equal net CSS', () => { + const compiled = compileGate(trivialPair.expected, { + filename: trivialPair.expectedPath, + }); + expect(compiled.ok).toBe(true); + if (!compiled.ok) { + return; + } + const before = emotionNetCss(trivialEmotionObject); + const after = netCssFromStylexMetadata(compiled.metadata); + const result = semanticDiffGate(before, after); + expect(result.ok).toBe(true); + }); + + test('FAILS end-to-end when the output renders a different value', () => { + const wrongExpected = trivialPair.expected.replace("'red'", "'blue'"); + const compiled = compileGate(wrongExpected); + expect(compiled.ok).toBe(true); + if (!compiled.ok) { + return; + } + const result = semanticDiffGate( + emotionNetCss(trivialEmotionObject), + netCssFromStylexMetadata(compiled.metadata), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.diffs).toEqual([ + expect.objectContaining({ + property: 'color', + beforeValue: 'red', + afterValue: 'blue', + }), + ]); + } + }); + + test('FAILS when a declaration is dropped entirely', () => { + const result = semanticDiffGate( + emotionNetCss({ color: 'red', fontSize: 16 }), + emotionNetCss({ color: 'red' }), + ); + expect(result.ok).toBe(false); + }); + + test('parses nested pseudo-class and media conditions', () => { + const net = emotionNetCss({ + color: 'red', + ':hover': { color: 'blue' }, + '@media (min-width: 600px)': { color: 'green' }, + }); + expect(Object.keys(net).sort()).toEqual([ + 'color', + 'color @ :hover', + 'color @ @media(min-width:600px)', + ]); + }); + + test('allowlist: the hover-guard is a sanctioned diff', () => { + const before = emotionNetCss({ ':hover': { color: 'blue' } }); + const after = emotionNetCss({ + '@media (hover: hover)': { ':hover': { color: 'blue' } }, + }); + const result = semanticDiffGate(before, after); + expect(result.ok).toBe(true); + expect(result.allowed.length).toBeGreaterThan(0); + }); + + test('allowlist: physical -> logical is a sanctioned diff', () => { + const result = semanticDiffGate( + emotionNetCss({ marginLeft: '8px' }), + emotionNetCss({ marginInlineStart: '8px' }), + ); + expect(result.ok).toBe(true); + expect(result.allowed.length).toBeGreaterThan(0); + }); + + test('allowlist does NOT excuse a value change under the same disguise', () => { + const result = semanticDiffGate( + emotionNetCss({ marginLeft: '8px' }), + emotionNetCss({ marginInlineStart: '9px' }), + ); + expect(result.ok).toBe(false); + }); + + test('BAILS LOUDLY on selectors that reach outside the element', () => { + expect(() => emotionNetCss({ '& > span': { color: 'red' } })).toThrow( + UnsupportedCssError, + ); + expect(() => emotionNetCss({ 'div span': { color: 'red' } })).toThrow( + UnsupportedCssError, + ); + }); + + test('BAILS LOUDLY on unrecognized StyleX metadata', () => { + expect(() => netCssFromStylexMetadata({ nope: true })).toThrow( + UnsupportedCssError, + ); + }); +}); diff --git a/packages/@stylexjs/codemods/__tests__/utils/harness.js b/packages/@stylexjs/codemods/__tests__/utils/harness.js new file mode 100644 index 000000000..7d5ee3503 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/utils/harness.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +export const FIXTURES_DIR: string = path.join( + __dirname, + '..', + '..', + '__fixtures__', +); + +/** + * Normalizes code through the Prettier CLI so fixture comparison is + * byte-exact but formatting-insensitive. + * + * Deliberately the CLI via spawnSync, NOT the Node API: Prettier 3's Node + * API is async-only and breaks under Jest's module sandbox. + */ +export function formatWithPrettier(code: string, filepath: string): string { + // $FlowFixMe[cannot-resolve-module] - untyped bin file + const bin = require.resolve('prettier/bin/prettier.cjs'); + const result = spawnSync( + process.execPath, + [bin, '--stdin-filepath', filepath], + { input: code, encoding: 'utf8' }, + ); + if (result.status !== 0) { + throw new Error( + `prettier failed for ${filepath}:\n${String(result.stderr)}`, + ); + } + // String(): Flow types spawnSync stdout as string | Buffer. + return String(result.stdout); +} + +export type Fixture = { + name: string, + inputPath: string, + expectedPath: string, + input: string, + expected: string, +}; + +/** Discovers all input/expected fixture pairs for an adapter. */ +export function loadFixtures(adapter: string): Array { + const dir = path.join(FIXTURES_DIR, adapter); + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + // String(): Flow's Dirent libdef types `name` as string | Buffer. + const name = String(entry.name); + const inputPath = path.join(dir, name, 'input.js'); + const expectedPath = path.join(dir, name, 'expected.js'); + return { + name, + inputPath, + expectedPath, + input: fs.readFileSync(inputPath, 'utf8'), + expected: fs.readFileSync(expectedPath, 'utf8'), + }; + }); +} + +export function readBrokenFixture(name: string): string { + return fs.readFileSync(path.join(FIXTURES_DIR, 'broken', name), 'utf8'); +} diff --git a/packages/@stylexjs/codemods/flow_modules/@emotion/serialize/index.js.flow b/packages/@stylexjs/codemods/flow_modules/@emotion/serialize/index.js.flow new file mode 100644 index 000000000..9fb1ac0bd --- /dev/null +++ b/packages/@stylexjs/codemods/flow_modules/@emotion/serialize/index.js.flow @@ -0,0 +1,17 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// Minimal type stub for `@emotion/serialize` (test-only devDependency; +// the semantic-diff gate's "before" ground truth). + +declare export function serializeStyles( + args: $ReadOnlyArray, + registered?: mixed, + props?: mixed, +): { +name: string, +styles: string, ... }; diff --git a/packages/@stylexjs/codemods/flow_modules/eslint/index.js.flow b/packages/@stylexjs/codemods/flow_modules/eslint/index.js.flow new file mode 100644 index 000000000..5df2bfdb0 --- /dev/null +++ b/packages/@stylexjs/codemods/flow_modules/eslint/index.js.flow @@ -0,0 +1,37 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// Minimal type stub for the parts of `eslint` the codemod uses. The real +// package's entry point lives under `lib/`, which this repo's .flowconfig +// ignores, so the module is unresolvable without a stub. + +export type LinterMessage = { + +ruleId: string | null, + +message: string, + +severity: number, + +line?: number, + +column?: number, + +fix?: mixed, + ... +}; + +declare export class Linter { + defineParser(name: string, parser: mixed): void; + defineRule(name: string, rule: mixed): void; + verify( + code: string, + config: mixed, + options?: mixed, + ): Array; + verifyAndFix( + code: string, + config: mixed, + options?: mixed, + ): { +fixed: boolean, +output: string, +messages: Array }; +} diff --git a/packages/@stylexjs/codemods/flow_modules/hermes-eslint/index.js.flow b/packages/@stylexjs/codemods/flow_modules/hermes-eslint/index.js.flow new file mode 100644 index 000000000..a0488e0b0 --- /dev/null +++ b/packages/@stylexjs/codemods/flow_modules/hermes-eslint/index.js.flow @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +// Minimal type stub for `hermes-eslint` (its compiled entry lives under +// `dist/`, which this repo's .flowconfig ignores). The module is +// ESM-compiled with NO default export — always use a namespace import; +// the namespace itself is the ESLint parser object. + +declare export function parseForESLint( + code: string, + options?: mixed, +): { +ast: mixed, +scopeManager: mixed, ... }; + +declare export function parse(code: string, options?: mixed): mixed; diff --git a/packages/@stylexjs/codemods/src/core/gates/compile.js b/packages/@stylexjs/codemods/src/core/gates/compile.js new file mode 100644 index 000000000..17a563335 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/gates/compile.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Compile gate: the output of a conversion must compile through the real + * `@stylexjs/babel-plugin`. On success it also returns the plugin's + * metadata (the injectable CSS rules), which the semantic-diff gate uses + * as the StyleX side's ground truth. + */ + +import * as babel from '@babel/core'; +import styleXPlugin from '@stylexjs/babel-plugin'; + +export type CompileGateResult = + | { +ok: true, +code: string, +metadata: mixed } + | { +ok: false, +errors: $ReadOnlyArray }; + +export function compileGate( + source: string, + options?: { +filename?: string }, +): CompileGateResult { + const filename = options?.filename ?? 'stylex-codemod-gate-input.js'; + try { + const result = babel.transformSync(source, { + filename, + babelrc: false, + configFile: false, + plugins: [ + ['babel-plugin-syntax-hermes-parser', { flow: 'detect' }], + [styleXPlugin, {}], + ], + }); + if (result == null || result.code == null) { + return { ok: false, errors: ['Babel produced no output'] }; + } + return { ok: true, code: result.code, metadata: result.metadata }; + } catch (error) { + return { + ok: false, + errors: [error instanceof Error ? error.message : String(error)], + }; + } +} diff --git a/packages/@stylexjs/codemods/src/core/gates/lint.js b/packages/@stylexjs/codemods/src/core/gates/lint.js new file mode 100644 index 000000000..c33abd1a4 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/gates/lint.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Lint gate: the output of a conversion must pass every rule of the real + * `@stylexjs/eslint-plugin` at severity `error`, with zero messages — + * which implies zero autofixes still needed (Meta's golden rule for + * StyleX codemods). + */ + +import { Linter } from 'eslint'; +// hermes-eslint is ESM-compiled with no default export — the namespace +// object itself is the parser (`parseForESLint` lives on it). +import * as hermesEslint from 'hermes-eslint'; +// The plugin has named exports only (no default) — import `rules` directly. +import { rules as stylexRules } from '@stylexjs/eslint-plugin'; + +export type LintMessage = { + +ruleId: string | null, + +message: string, + +line: number, + +column: number, + +fixable: boolean, +}; + +export type LintGateResult = + | { +ok: true } + | { +ok: false, +messages: $ReadOnlyArray }; + +const PARSER_NAME = 'hermes-eslint'; + +function buildLinter(): { linter: Linter, rules: { [string]: 'error' } } { + const linter = new Linter(); + linter.defineParser(PARSER_NAME, hermesEslint); + const ruleMap: { +[string]: mixed } = stylexRules; + const rules: { [string]: 'error' } = {}; + for (const ruleName of Object.keys(ruleMap)) { + const qualified = `@stylexjs/${ruleName}`; + linter.defineRule(qualified, ruleMap[ruleName]); + rules[qualified] = 'error'; + } + return { linter, rules }; +} + +export function lintGate( + source: string, + options?: { +filename?: string }, +): LintGateResult { + const { linter, rules } = buildLinter(); + const messages = linter.verify( + source, + { + parser: PARSER_NAME, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules, + }, + { filename: options?.filename ?? 'stylex-codemod-gate-input.js' }, + ); + if (messages.length === 0) { + return { ok: true }; + } + return { + ok: false, + messages: messages.map((m) => ({ + ruleId: m.ruleId ?? null, + message: m.message, + line: m.line ?? 0, + column: m.column ?? 0, + fixable: m.fix != null, + })), + }; +} diff --git a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js new file mode 100644 index 000000000..ad99f6080 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js @@ -0,0 +1,376 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Semantic-diff gate: the net CSS before and after a conversion must be + * identical across every condition combination, minus an explicit allowlist + * of sanctioned changes (hover-guard, physical->logical). This is the check + * that catches "compiles fine but renders differently". + * + * Ground truths: + * - before: Emotion's own serializer (`@emotion/serialize`) output, + * parsed by `netCssFromSerializedCss`. + * - after: the real `@stylexjs/babel-plugin` metadata (injectable rules), + * parsed by `netCssFromStylexMetadata`. + * + * Both parsers BAIL LOUDLY (throw `UnsupportedCssError`) on any construct + * they cannot provably represent — an unparsable input must never diff + * clean by accident. + */ + +/** One declaration of net CSS: a property+conditions coordinate => value. */ +export type NetDeclaration = { + +property: string, // kebab-case + +conditions: $ReadOnlyArray, // normalized, sorted + +value: string, +}; + +export type NetCss = { +[coordinate: string]: NetDeclaration }; + +export type DiffEntry = { + +coordinate: string, + +property: string, + +conditions: $ReadOnlyArray, + +beforeValue: string | null, + +afterValue: string | null, +}; + +export type AllowlistRule = ( + entry: DiffEntry, + before: NetCss, + after: NetCss, +) => boolean; + +export type SemanticDiffResult = + | { +ok: true, +allowed: $ReadOnlyArray } + | { + +ok: false, + +diffs: $ReadOnlyArray, + +allowed: $ReadOnlyArray, + }; + +export class UnsupportedCssError extends Error { + constructor(message: string) { + super(`[stylex-codemod semantic-diff] unsupported CSS: ${message}`); + this.name = 'UnsupportedCssError'; + } +} + +// --- normalization helpers --------------------------------------------- + +function normalizeAtRule(rule: string): string { + return rule + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/\s*([():,])\s*/g, '$1') + .trim(); +} + +function normalizeValue(value: string): string { + return value.trim().replace(/\s+/g, ' '); +} + +function normalizeCondition(raw: string): string { + let s = raw.trim(); + if (s.startsWith('&')) { + s = s.slice(1).trim(); + } + if (s.startsWith('@')) { + return normalizeAtRule(s); + } + if (/^::?[\w-]+(\([^()]*\))?$/.test(s)) { + return s.toLowerCase(); + } + throw new UnsupportedCssError( + `selector context '${raw}' is not a self-targeting pseudo or at-rule`, + ); +} + +function coordinate( + property: string, + conditions: $ReadOnlyArray, +): string { + return conditions.length === 0 + ? property + : `${property} @ ${conditions.join(' && ')}`; +} + +function addDeclaration( + target: { [string]: NetDeclaration }, + property: string, + rawConditions: $ReadOnlyArray, + value: string, +): void { + const conditions = rawConditions.map(normalizeCondition).slice().sort(); + const prop = property.trim().toLowerCase(); + if (!/^--|^[a-z-]+$/.test(prop)) { + throw new UnsupportedCssError(`property '${property}'`); + } + target[coordinate(prop, conditions)] = { + property: prop, + conditions, + value: normalizeValue(value), + }; +} + +// --- before: Emotion serialized CSS ------------------------------------ + +/** + * Parses the `styles` string produced by `@emotion/serialize` (flat + * declarations plus one-or-more levels of `pseudo { ... }` / `@media { ... }` + * nesting) into net CSS. Throws on any selector that is not self-targeting. + */ +export function netCssFromSerializedCss(css: string): NetCss { + const out: { [string]: NetDeclaration } = {}; + const contexts: Array = []; + let buffer = ''; + + const flushDeclaration = (chunk: string) => { + const decl = chunk.trim(); + if (decl === '') { + return; + } + const colon = decl.indexOf(':'); + if (colon <= 0) { + throw new UnsupportedCssError(`declaration '${decl}'`); + } + addDeclaration(out, decl.slice(0, colon), contexts, decl.slice(colon + 1)); + }; + + for (let i = 0; i < css.length; i++) { + const char = css[i]; + if (char === '{') { + const selector = buffer.trim(); + if (selector === '') { + throw new UnsupportedCssError('block with empty selector'); + } + if (selector.includes(',')) { + throw new UnsupportedCssError(`selector list '${selector}'`); + } + contexts.push(selector); + buffer = ''; + } else if (char === '}') { + flushDeclaration(buffer); + buffer = ''; + if (contexts.length === 0) { + throw new UnsupportedCssError('unbalanced closing brace'); + } + contexts.pop(); + } else if (char === ';') { + flushDeclaration(buffer); + buffer = ''; + } else { + buffer += char; + } + } + if (contexts.length !== 0) { + throw new UnsupportedCssError('unbalanced opening brace'); + } + flushDeclaration(buffer); + return out; +} + +// --- after: StyleX babel-plugin metadata -------------------------------- + +/** + * Parses `metadata.stylex` from a `compileGate` run — entries of + * `[className, { ltr, rtl }, priority]` — into net CSS. Rules are applied + * in ascending priority order, mirroring how StyleX resolves collisions. + */ +export function netCssFromStylexMetadata(metadata: mixed): NetCss { + const rules = + metadata != null && + typeof metadata === 'object' && + Array.isArray(metadata.stylex) + ? metadata.stylex + : Array.isArray(metadata) + ? metadata + : null; + if (rules == null) { + throw new UnsupportedCssError( + 'expected StyleX babel-plugin metadata ({ stylex: [...] })', + ); + } + const entries = rules + .map((rule: mixed): { ltr: string, priority: number } => { + if (Array.isArray(rule)) { + const styles = rule[1]; + const priority = rule[2]; + if ( + styles != null && + typeof styles === 'object' && + typeof styles.ltr === 'string' && + typeof priority === 'number' + ) { + return { ltr: styles.ltr, priority }; + } + } + throw new UnsupportedCssError( + `metadata rule ${JSON.stringify(rule) ?? 'undefined'}`, + ); + }) + .sort((a, b) => a.priority - b.priority); + + const out: { [string]: NetDeclaration } = {}; + for (const { ltr } of entries) { + parseStylexRule(ltr, out); + } + return out; +} + +function parseStylexRule(ltr: string, out: { [string]: NetDeclaration }): void { + let rest = ltr.trim(); + const atRules: Array = []; + while (rest.startsWith('@')) { + const brace = rest.indexOf('{'); + if (brace < 0 || !rest.endsWith('}')) { + throw new UnsupportedCssError(`at-rule '${ltr}'`); + } + atRules.push(rest.slice(0, brace)); + rest = rest.slice(brace + 1, -1).trim(); + } + const match = + /^((?:\.[\w-]+)+)((?:::?[\w-]+(?:\([^()]*\))?)*)\{([^{}]*)\}$/.exec(rest); + if (match == null) { + throw new UnsupportedCssError(`rule '${ltr}'`); + } + const pseudoChain = match[2]; + const pseudos = + pseudoChain === '' + ? [] + : (pseudoChain.match(/::?[\w-]+(\([^()]*\))?/g) ?? []); + const conditions = [...atRules, ...pseudos]; + for (const chunk of match[3].split(';')) { + const decl = chunk.trim(); + if (decl === '') { + continue; + } + const colon = decl.indexOf(':'); + if (colon <= 0) { + throw new UnsupportedCssError(`declaration '${decl}' in '${ltr}'`); + } + addDeclaration( + out, + decl.slice(0, colon), + conditions, + decl.slice(colon + 1), + ); + } +} + +// --- the allowlist (sanctioned, intentional diffs) ---------------------- + +const HOVER_GUARD = normalizeAtRule('@media (hover: hover)'); + +const PHYSICAL_TO_LOGICAL: { +[string]: string } = { + 'margin-left': 'margin-inline-start', + 'margin-right': 'margin-inline-end', + 'padding-left': 'padding-inline-start', + 'padding-right': 'padding-inline-end', + 'border-left': 'border-inline-start', + 'border-right': 'border-inline-end', + left: 'inset-inline-start', + right: 'inset-inline-end', +}; + +const LOGICAL_TO_PHYSICAL: { [string]: string } = {}; +for (const physical of Object.keys(PHYSICAL_TO_LOGICAL)) { + LOGICAL_TO_PHYSICAL[PHYSICAL_TO_LOGICAL[physical]] = physical; +} + +/** + * Sanctioned: a physical property on the before side replaced by its + * logical twin (same conditions, same value) on the after side. + */ +export const allowPhysicalToLogical: AllowlistRule = (entry, before, after) => { + if (entry.beforeValue != null && entry.afterValue == null) { + const logical = PHYSICAL_TO_LOGICAL[entry.property]; + return ( + logical != null && + after[coordinate(logical, entry.conditions)]?.value === entry.beforeValue + ); + } + if (entry.afterValue != null && entry.beforeValue == null) { + const physical = LOGICAL_TO_PHYSICAL[entry.property]; + return ( + physical != null && + before[coordinate(physical, entry.conditions)]?.value === entry.afterValue + ); + } + return false; +}; + +/** + * Sanctioned: a `:hover` declaration additionally wrapped in + * `@media (hover: hover)` on the after side (the hover-guard, on by + * default in the codemod). + */ +export const allowHoverGuard: AllowlistRule = (entry, before, after) => { + const hasHover = entry.conditions.some((c) => c.startsWith(':hover')); + if (!hasHover) { + return false; + } + if (entry.beforeValue != null && entry.afterValue == null) { + const guarded = [...entry.conditions, HOVER_GUARD].sort(); + return ( + after[coordinate(entry.property, guarded)]?.value === entry.beforeValue + ); + } + if ( + entry.afterValue != null && + entry.beforeValue == null && + entry.conditions.includes(HOVER_GUARD) + ) { + const unguarded = entry.conditions.filter((c) => c !== HOVER_GUARD); + return ( + before[coordinate(entry.property, unguarded)]?.value === entry.afterValue + ); + } + return false; +}; + +export const DEFAULT_ALLOWLIST: $ReadOnlyArray = [ + allowPhysicalToLogical, + allowHoverGuard, +]; + +// --- the gate ----------------------------------------------------------- + +export function semanticDiffGate( + before: NetCss, + after: NetCss, + options?: { +allowlist?: $ReadOnlyArray }, +): SemanticDiffResult { + const allowlist = options?.allowlist ?? DEFAULT_ALLOWLIST; + const coordinates = new Set([...Object.keys(before), ...Object.keys(after)]); + const diffs: Array = []; + const allowed: Array = []; + for (const coord of coordinates) { + const b = before[coord]; + const a = after[coord]; + if (b != null && a != null && b.value === a.value) { + continue; + } + const entry: DiffEntry = { + coordinate: coord, + property: (b ?? a)?.property ?? coord, + conditions: (b ?? a)?.conditions ?? [], + beforeValue: b?.value ?? null, + afterValue: a?.value ?? null, + }; + if (allowlist.some((rule) => rule(entry, before, after))) { + allowed.push(entry); + } else { + diffs.push(entry); + } + } + return diffs.length === 0 + ? { ok: true, allowed } + : { ok: false, diffs, allowed }; +} From d485a6c6e8b439528abe76793f2eedb152de65da Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Fri, 17 Jul 2026 23:19:16 -0400 Subject: [PATCH 04/32] codemods: IR-completeness harness stub (measured coverage, 0/5 at M0) Co-Authored-By: Claude Fable 5 --- .../__tests__/ir-completeness-test.js | 67 +++++++++++++++++++ .../codemods/src/testing/stylexToIR.js | 37 ++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/@stylexjs/codemods/__tests__/ir-completeness-test.js create mode 100644 packages/@stylexjs/codemods/src/testing/stylexToIR.js diff --git a/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js new file mode 100644 index 000000000..6118011a0 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * IR-completeness harness (M0 stub). + * + * Completeness is MEASURED, not asserted: every valid StyleX style object + * in the corpus is read into the IR; whatever cannot be represented is a + * concrete, safe coverage gap (in the real pipeline it would be flagged, + * never emitted incorrectly). The reader lands with the M1 emitter — until then + * every entry counts as uncovered and the reported coverage is 0%. + * + * M1+: replace the seed corpus with extraction from StyleX's own valid + * fixtures (`@stylexjs/babel-plugin` / `@stylexjs/eslint-plugin` tests), + * and round-trip each covered entry (IR -> emit -> compile + semantic-diff). + */ + +import { + stylexObjectToIR, + IRCoverageGapError, +} from '../src/testing/stylexToIR'; + +const SEED_CORPUS: $ReadOnlyArray<[string, mixed]> = [ + ['flat static styles', { color: 'red', fontSize: 16 }], + [ + 'pseudo-class conditions in the value object', + { color: { default: 'black', ':hover': 'blue' } }, + ], + // Valid modern StyleX — pseudo-elements may appear inside value objects. + [ + 'pseudo-element condition in the value object', + { color: { default: 'black', '::placeholder': 'gray' } }, + ], + [ + 'at-rule conditions', + { width: { default: '100%', '@media (min-width: 600px)': '50%' } }, + ], + ['fallback array (firstThatWorks)', { position: ['sticky', 'fixed'] }], +]; + +test('every corpus entry is either covered by the IR or a typed coverage gap', () => { + let covered = 0; + const gaps: Array = []; + for (const [name, styleObject] of SEED_CORPUS) { + try { + stylexObjectToIR(name, styleObject); + covered += 1; + } catch (error) { + if (!(error instanceof IRCoverageGapError)) { + throw error; // a real bug, not a coverage gap + } + gaps.push(name); + } + } + expect(covered + gaps.length).toBe(SEED_CORPUS.length); + // eslint-disable-next-line no-console + console.info( + `[ir-completeness] ${covered}/${SEED_CORPUS.length} corpus entries covered` + + (gaps.length > 0 ? ` — gaps: ${gaps.join(', ')}` : ''), + ); +}); diff --git a/packages/@stylexjs/codemods/src/testing/stylexToIR.js b/packages/@stylexjs/codemods/src/testing/stylexToIR.js new file mode 100644 index 000000000..a80eee274 --- /dev/null +++ b/packages/@stylexjs/codemods/src/testing/stylexToIR.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * TEST-ONLY reader: a valid `stylex.create` style object -> IR. + * + * This powers the IR-completeness harness: round-tripping StyleX's own + * valid-input corpus (read -> IR -> re-emit -> still compiles & semantically + * identical) turns "is the IR complete?" from a claim into a measured + * coverage percentage. An IR gap found here is SAFE — in the real pipeline + * the same construct would be flagged, never emitted incorrectly. + * + * Not implemented until M1 (it needs the emitter to round-trip against); + * the harness counts every corpus entry as uncovered until then. + */ + +import type { StyleRule } from '../core/ir'; + +export class IRCoverageGapError extends Error { + constructor(message: string) { + super(`[stylex-codemod ir-completeness] ${message}`); + this.name = 'IRCoverageGapError'; + } +} + +// eslint-disable-next-line no-unused-vars +export function stylexObjectToIR(name: string, styleObject: mixed): StyleRule { + throw new IRCoverageGapError( + 'stylexObjectToIR is not implemented yet (lands with the M1 emitter)', + ); +} From b4c877f27cf850fe89482fd42f3b855c98397bf9 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 12:13:43 -0400 Subject: [PATCH 05/32] =?UTF-8?q?codemods:=20core=20engine=20=E2=80=94=20b?= =?UTF-8?q?uildIR=20(L4)=20+=20emit=20(L7),=20tested=20against=20hand-writ?= =?UTF-8?q?ten=20IR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../@stylexjs/codemods/__tests__/emit-test.js | 163 ++++++++++++++++++ .../@stylexjs/codemods/src/core/buildIR.js | 51 ++++++ packages/@stylexjs/codemods/src/core/emit.js | 122 +++++++++++++ .../@stylexjs/codemods/src/core/rewriter.js | 26 ++- 4 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 packages/@stylexjs/codemods/__tests__/emit-test.js create mode 100644 packages/@stylexjs/codemods/src/core/buildIR.js create mode 100644 packages/@stylexjs/codemods/src/core/emit.js diff --git a/packages/@stylexjs/codemods/__tests__/emit-test.js b/packages/@stylexjs/codemods/__tests__/emit-test.js new file mode 100644 index 000000000..84b972782 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/emit-test.js @@ -0,0 +1,163 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Engine unit tests: hand-written IR in, emitted create data out. + * Deliberately NO Emotion anywhere in this file — this is what proves the + * engine is source-neutral (any adapter producing this IR gets the same + * output). + */ + +import type { FileIR } from '../src/core/ir'; +import { buildFileIR } from '../src/core/buildIR'; +import { emitFileIR, sanitizeKey, EmitError } from '../src/core/emit'; + +function irOf( + entries: Array<[string, Array<[string, string | number]>]>, +): FileIR { + return { + rules: entries.map(([name, decls]) => ({ + name, + atoms: decls.map(([property, value]) => ({ + property, + conditions: [], + value: { kind: 'static', value }, + })), + })), + keyframes: [], + }; +} + +describe('emitFileIR', () => { + test('one rule becomes one create entry plus its binding', () => { + const result = emitFileIR(irOf([['badge', [['color', 'red']]]])); + expect(result.rules).toEqual([{ key: 'badge', style: { color: 'red' } }]); + expect(result.bindings).toEqual(['badge']); + }); + + test('sorts properties alphabetically (stylex/sort-keys, zero autofixes) and keeps value types', () => { + // Safe for flat longhands: order-dependent cases (duplicates, + // shorthand/longhand overlap) are refused before emit. + const result = emitFileIR( + irOf([ + [ + 'card', + [ + ['fontSize', 16], + ['lineHeight', 1.5], + ['color', 'rgb(0, 0, 0)'], + ], + ], + ]), + ); + expect(Object.entries(result.rules[0].style)).toEqual([ + ['color', 'rgb(0, 0, 0)'], + ['fontSize', 16], + ['lineHeight', 1.5], + ]); + }); + + test('key collisions get numeric suffixes in rule order', () => { + const result = emitFileIR( + irOf([ + ['badge', [['color', 'red']]], + ['badge', [['color', 'blue']]], + ['badge', [['color', 'green']]], + ]), + ); + expect(result.bindings).toEqual(['badge', 'badge2', 'badge3']); + }); + + test('sanitizes hostile name hints instead of emitting bad keys', () => { + expect(sanitizeKey('MyButton')).toBe('myButton'); + expect(sanitizeKey('nav-bar item')).toBe('navBarItem'); + expect(sanitizeKey('123')).toBe('styles'); + expect(sanitizeKey('')).toBe('styles'); + expect(sanitizeKey('default')).toBe('styles'); + }); + + test('REFUSES conditions (M2) rather than emitting incorrectly', () => { + const ir: FileIR = { + rules: [ + { + name: 'badge', + atoms: [ + { + property: 'color', + conditions: [{ kind: 'pseudo-class', name: ':hover' }], + value: { kind: 'static', value: 'blue' }, + }, + ], + }, + ], + keyframes: [], + }; + expect(() => emitFileIR(ir)).toThrow(EmitError); + }); + + test('REFUSES duplicate properties within a rule', () => { + const ir = irOf([ + [ + 'badge', + [ + ['color', 'red'], + ['color', 'blue'], + ], + ], + ]); + expect(() => emitFileIR(ir)).toThrow(EmitError); + }); +}); + +describe('buildFileIR', () => { + test('flat declarations become zero-condition static atoms', () => { + const ir = buildFileIR([ + { + nameHint: 'badge', + declarations: [ + { property: 'color', value: 'red' }, + { property: 'fontSize', value: 16 }, + ], + }, + ]); + expect(ir).toEqual({ + rules: [ + { + name: 'badge', + atoms: [ + { + property: 'color', + conditions: [], + value: { kind: 'static', value: 'red' }, + }, + { + property: 'fontSize', + conditions: [], + value: { kind: 'static', value: 16 }, + }, + ], + }, + ], + keyframes: [], + }); + }); + + test('buildFileIR -> emitFileIR round-trips a group into create data', () => { + const { rules, bindings } = emitFileIR( + buildFileIR([ + { + nameHint: 'Badge', + declarations: [{ property: 'color', value: 'red' }], + }, + ]), + ); + expect(rules).toEqual([{ key: 'badge', style: { color: 'red' } }]); + expect(bindings).toEqual(['badge']); + }); +}); diff --git a/packages/@stylexjs/codemods/src/core/buildIR.js b/packages/@stylexjs/codemods/src/core/buildIR.js new file mode 100644 index 000000000..6b6d0c205 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/buildIR.js @@ -0,0 +1,51 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L4 — Build IR. Turns adapter-produced `declarations` (the first seam + * hand-off) into the neutral FileIR. + * + * M1 scope: flat static declarations only — every atom has zero + * conditions. "The flip" (selector-grouped -> property-grouped condition + * nesting) lands here in M2; this module's shape is already the flip's + * home so the seam does not move. + */ + +import type { Atom, FileIR, StyleRule } from './ir'; + +/** + * One style declaration as handed over by an adapter: no conditions yet + * (M1), no StyleX knowledge, no AST nodes. + */ +export type Declaration = { + +property: string, + +value: string | number, +}; + +/** + * One style site as handed over by an adapter: its declarations plus a + * suggested name (adapter knows labels/component names; core decides the + * final key). + */ +export type DeclarationGroup = { + +nameHint: string, + +declarations: $ReadOnlyArray, +}; + +export function buildFileIR(groups: $ReadOnlyArray): FileIR { + const rules: Array = groups.map((group) => { + const atoms: Array = group.declarations.map((declaration) => ({ + property: declaration.property, + conditions: [], + value: { kind: 'static', value: declaration.value }, + })); + return { name: group.nameHint, atoms }; + }); + return { rules, keyframes: [] }; +} diff --git a/packages/@stylexjs/codemods/src/core/emit.js b/packages/@stylexjs/codemods/src/core/emit.js new file mode 100644 index 000000000..7fc62104a --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/emit.js @@ -0,0 +1,122 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L7 — Emit. Turns the FileIR into the data for a single per-file + * `stylex.create` registry plus the binding map (the second seam + * hand-off: rule #N becomes `styles.`). + * + * Deliberately AST-free: emit produces plain data; the adapter (which owns + * the file's AST) renders it. This keeps `core/` blind to parser nodes. + * + * Naming (M1 minimal policy, superseded wholesale in M4): the adapter's + * nameHint (label > enclosing component) is sanitized to a camelCase + * identifier; collisions get a numeric suffix starting at 2. + */ + +import type { FileIR, StyleRule, Value } from './ir'; + +/** + * A value in a `stylex.create` entry: a static value, or a fallback array + * (StyleX's shorthand for `firstThatWorks`). + */ +export type EmittedValue = string | number | $ReadOnlyArray; + +/** A `stylex.create` entry as plain data: property -> value. */ +export type EmittedStyle = { +[property: string]: EmittedValue }; + +export type EmittedRule = { + +key: string, + +style: EmittedStyle, +}; + +export type EmitResult = { + +rules: $ReadOnlyArray, + /** bindings[i] is the create key for FileIR.rules[i]. */ + +bindings: $ReadOnlyArray, +}; + +export class EmitError extends Error { + constructor(message: string) { + super(`[stylex-codemod emit] ${message}`); + this.name = 'EmitError'; + } +} + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const RESERVED = new Set(['default', 'delete', 'do', 'in', 'new', 'var']); + +/** Sanitizes an adapter name hint into a usable create key. */ +export function sanitizeKey(hint: string): string { + const cleaned = hint + .replace(/[^A-Za-z0-9_$]+(.)?/g, (_, next: string | void) => + next == null ? '' : next.toUpperCase(), + ) + .replace(/^[0-9]+/, ''); + const key = + cleaned === '' ? 'styles' : cleaned[0].toLowerCase() + cleaned.slice(1); + return IDENTIFIER.test(key) && !RESERVED.has(key) ? key : 'styles'; +} + +function emitValue(value: Value, rule: StyleRule): EmittedValue { + if (value.kind === 'first-that-works') { + return value.values; + } + if (value.kind !== 'static' || value.value == null) { + throw new EmitError( + `rule '${rule.name}': only static non-null values are emittable in M1`, + ); + } + return value.value; +} + +export function emitFileIR(ir: FileIR): EmitResult { + const usedKeys = new Set(); + const rules: Array = []; + const bindings: Array = []; + + for (const rule of ir.rules) { + const unsorted: { [string]: EmittedValue } = {}; + for (const atom of rule.atoms) { + if (atom.conditions.length > 0) { + throw new EmitError( + `rule '${rule.name}': conditions are not emittable until M2`, + ); + } + if (unsorted[atom.property] !== undefined) { + throw new EmitError( + `rule '${rule.name}': duplicate property '${atom.property}'`, + ); + } + unsorted[atom.property] = emitValue(atom.value, rule); + } + // Alphabetical property order satisfies stylex/sort-keys with zero + // autofixes. Safe in M1: flat longhands are order-independent, and the + // order-DEPENDENT cases (duplicates, shorthand/longhand overlap) are + // refused upstream. M4's scoped verifyAndFix supersedes this. + const style: { [string]: EmittedValue } = {}; + for (const property of Object.keys(unsorted).sort()) { + style[property] = unsorted[property]; + } + + const base = sanitizeKey(rule.name); + let key = base; + for (let n = 2; usedKeys.has(key); n++) { + key = `${base}${n}`; + } + usedKeys.add(key); + rules.push({ key, style }); + bindings.push(key); + } + + if (ir.keyframes.length > 0) { + throw new EmitError('keyframes are not emittable until M3'); + } + return { rules, bindings }; +} diff --git a/packages/@stylexjs/codemods/src/core/rewriter.js b/packages/@stylexjs/codemods/src/core/rewriter.js index 74bc8135c..c02ad8d18 100644 --- a/packages/@stylexjs/codemods/src/core/rewriter.js +++ b/packages/@stylexjs/codemods/src/core/rewriter.js @@ -15,15 +15,18 @@ * we have deliberately kept open. * * jscodeshift is the default: format-preserving printing via recast, and - * parses the Flow/TS/JSX found in user code. + * parses the Flow/TS/JSX found in user code. Adapters receive `j` and + * `root` from here and never import the toolkit themselves (enforced by + * seam-test); `core/` outside this file never sees an AST node at all. */ import jscodeshift from 'jscodeshift'; +import type { EmittedStyle, EmittedValue } from './emit'; // 'flow' also covers plain JS + JSX; 'tsx' also covers plain TS. export type ParserChoice = 'flow' | 'tsx'; -export opaque type Rewriter = { +export type Rewriter = { +j: $FlowFixMe, +root: $FlowFixMe, }; @@ -46,3 +49,22 @@ export function parseSource( export function printSource(rewriter: Rewriter): string { return rewriter.root.toSource({ quote: 'single' }); } + +/** + * Renders emitted style data (plain values / fallback arrays) as an + * ObjectExpression — the bridge that lets `core/emit.js` stay AST-free. + */ +export function styleToObjectAst( + j: $FlowFixMe, + style: EmittedStyle, +): $FlowFixMe { + const valueAst = (value: EmittedValue): $FlowFixMe => + Array.isArray(value) + ? j.arrayExpression(value.map((v) => j.literal(v))) + : j.literal(value); + return j.objectExpression( + Object.keys(style).map((property) => + j.property('init', j.identifier(property), valueAst(style[property])), + ), + ); +} From eb47bf7a349da7e915ed37063e6762621cbab8ec Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 12:17:30 -0400 Subject: [PATCH 06/32] =?UTF-8?q?codemods:=20Emotion=20adapter=20=E2=80=94?= =?UTF-8?q?=20detect=20(L2),=20read=20(L3),=20rewrite=20(L8),=20imports,?= =?UTF-8?q?=20orchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../codemods/src/adapters/emotion/detect.js | 135 ++++++++++++++ .../codemods/src/adapters/emotion/imports.js | 145 +++++++++++++++ .../codemods/src/adapters/emotion/read.js | 167 ++++++++++++++++++ .../src/adapters/emotion/rewriteSites.js | 31 ++++ .../src/adapters/emotion/transform.js | 165 +++++++++++++++++ 5 files changed, 643 insertions(+) create mode 100644 packages/@stylexjs/codemods/src/adapters/emotion/detect.js create mode 100644 packages/@stylexjs/codemods/src/adapters/emotion/imports.js create mode 100644 packages/@stylexjs/codemods/src/adapters/emotion/read.js create mode 100644 packages/@stylexjs/codemods/src/adapters/emotion/rewriteSites.js create mode 100644 packages/@stylexjs/codemods/src/adapters/emotion/transform.js diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js new file mode 100644 index 000000000..c44ac5156 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js @@ -0,0 +1,135 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L2 — Detect. Finds every Emotion style site in the file and everything + * that makes the file unconvertible. M1 policy (user-ratified): a file is + * converted only if EVERY style site is convertible; a single blocker + * skips the whole file untouched. + * + * Convertible site forms: + *
— object literal + *
— inline `css()` call around an object + * on host (lowercase) elements with no `className`/`style`/spread props. + */ + +export type StyleSite = { + +attrPath: $FlowFixMe, // JSXAttribute path + +objectNode: $FlowFixMe, // the style ObjectExpression + +tagName: string, +}; + +export type Detection = { + +sites: Array, + +blockers: Array, +}; + +export function detectSites( + j: $FlowFixMe, + root: $FlowFixMe, + cssLocalName: string | null, +): Detection { + const sites: Array = []; + const blockers: Array = []; + const siteCallees = new Set<$FlowFixMe>(); + + root + .find(j.JSXAttribute, { name: { name: 'css' } }) + .forEach((path: $FlowFixMe) => { + const opening = path.parent.node; + const nameNode = opening.name; + if (nameNode.type !== 'JSXIdentifier' || !/^[a-z]/.test(nameNode.name)) { + blockers.push( + 'css prop on a component element (className forwarding is not provable)', + ); + return; + } + const conflicting = (opening.attributes ?? []).find( + (attr: $FlowFixMe) => + attr.type === 'JSXSpreadAttribute' || + attr.name?.name === 'className' || + attr.name?.name === 'style', + ); + if (conflicting != null) { + blockers.push( + `<${nameNode.name}> mixes css with className/style/spread props`, + ); + return; + } + const container = path.node.value; + if (container?.type !== 'JSXExpressionContainer') { + blockers.push(`css prop on <${nameNode.name}> is not an expression`); + return; + } + const expression = container.expression; + if (expression.type === 'ObjectExpression') { + sites.push({ + attrPath: path, + objectNode: expression, + tagName: nameNode.name, + }); + return; + } + if ( + expression.type === 'CallExpression' && + expression.callee.type === 'Identifier' && + cssLocalName != null && + expression.callee.name === cssLocalName && + expression.arguments.length === 1 && + expression.arguments[0].type === 'ObjectExpression' + ) { + siteCallees.add(expression.callee); + sites.push({ + attrPath: path, + objectNode: expression.arguments[0], + tagName: nameNode.name, + }); + return; + } + blockers.push( + `css prop value form on <${nameNode.name}> is not convertible yet ` + + '(template literals, arrays, references and dynamic styles land in later milestones)', + ); + }); + + if (cssLocalName != null) { + root + .find(j.Identifier, { name: cssLocalName }) + .forEach((path: $FlowFixMe) => { + const parentNode = path.parent.node; + if ( + parentNode.type === 'ImportSpecifier' || + siteCallees.has(path.node) || + (parentNode.type === 'Property' && parentNode.key === path.node) || + parentNode.type === 'JSXAttribute' + ) { + return; + } + blockers.push( + `'${cssLocalName}' is used outside a convertible css prop ` + + '(tagged templates / shared style variables land in later milestones)', + ); + }); + } + + if ( + root + .find(j.ImportDeclaration) + .some( + (path: $FlowFixMe) => + String(path.node.source.value) === '@stylexjs/stylex', + ) + ) { + blockers.push( + 'file already imports @stylexjs/stylex (registry merge lands in M4)', + ); + } + + return { sites, blockers }; +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/imports.js b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js new file mode 100644 index 000000000..a5090651d --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js @@ -0,0 +1,145 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Emotion wiring: how a file opts into the css prop. M1 recognizes the + * modern `@emotion/react` forms only — the `@jsxImportSource` pragma and a + * named `css` import. Any other `@emotion/*` surface (styled, class-based + * `@emotion/css`, Global, keyframes, classic `jsx` pragma) is a blocker: + * we skip the whole file rather than half-migrate it. + */ + +const PRAGMA_PATTERN = /@jsxImportSource\s+@emotion\/react/; + +export type EmotionWiring = { + +hasPragma: boolean, + +cssLocalName: string | null, + +blockers: Array, +}; + +export function analyzeEmotionWiring( + j: $FlowFixMe, + root: $FlowFixMe, +): EmotionWiring { + let cssLocalName: string | null = null; + const blockers: Array = []; + + root.find(j.ImportDeclaration).forEach((path: $FlowFixMe) => { + const source = String(path.node.source.value); + if (!source.startsWith('@emotion/')) { + return; + } + if (source !== '@emotion/react') { + blockers.push(`import from '${source}' is not convertible yet`); + return; + } + for (const specifier of path.node.specifiers ?? []) { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.name === 'css' + ) { + cssLocalName = specifier.local.name; + } else { + blockers.push( + `'@emotion/react' import of '${ + specifier.imported?.name ?? specifier.local?.name ?? '?' + }' is not convertible yet`, + ); + } + } + }); + + return { + hasPragma: PRAGMA_PATTERN.test(findPragmaText(j, root) ?? ''), + cssLocalName, + blockers, + }; +} + +function allCommentSlots(j: $FlowFixMe, root: $FlowFixMe): Array<$FlowFixMe> { + const program = root.get().node.program; + const slots = [program]; + if (program.body.length > 0) { + slots.push(program.body[0]); + } + return slots; +} + +function findPragmaText(j: $FlowFixMe, root: $FlowFixMe): string | null { + for (const slot of allCommentSlots(j, root)) { + for (const comment of slot.comments ?? []) { + if (PRAGMA_PATTERN.test(comment.value)) { + return comment.value; + } + } + } + return null; +} + +/** Removes the `@jsxImportSource @emotion/react` pragma comment. */ +export function removePragma(j: $FlowFixMe, root: $FlowFixMe): void { + for (const slot of allCommentSlots(j, root)) { + if (slot.comments != null) { + slot.comments = slot.comments.filter( + (comment: $FlowFixMe) => !PRAGMA_PATTERN.test(comment.value), + ); + } + } +} + +/** + * Removes the `css` specifier from the `@emotion/react` import (the whole + * declaration when nothing else remains), transplanting any non-pragma + * comments onto the next statement so file headers survive. + */ +export function removeCssImport(j: $FlowFixMe, root: $FlowFixMe): void { + root.find(j.ImportDeclaration).forEach((path: $FlowFixMe) => { + if (String(path.node.source.value) !== '@emotion/react') { + return; + } + const remaining = (path.node.specifiers ?? []).filter( + (specifier: $FlowFixMe) => + !( + specifier.type === 'ImportSpecifier' && + specifier.imported.name === 'css' + ), + ); + if (remaining.length > 0) { + path.node.specifiers = remaining; + return; + } + const comments = (path.node.comments ?? []).filter( + (comment: $FlowFixMe) => + comment.leading === true && !PRAGMA_PATTERN.test(comment.value), + ); + const next = path.parent.node.body[path.name + 1]; + if (comments.length > 0 && next != null) { + next.comments = [...comments, ...(next.comments ?? [])]; + } + j(path).remove(); + }); +} + +/** + * Inserts `import * as stylex from '@stylexjs/stylex';` after the last + * import. Detection of a pre-existing StyleX import is a blocker upstream + * (registry merge lands in M4), so this never duplicates. + */ +export function insertStylexImport(j: $FlowFixMe, root: $FlowFixMe): void { + const declaration = j.importDeclaration( + [j.importNamespaceSpecifier(j.identifier('stylex'))], + j.literal('@stylexjs/stylex'), + ); + const imports = root.find(j.ImportDeclaration); + if (imports.size() > 0) { + j(imports.paths()[imports.size() - 1]).insertAfter(declaration); + } else { + root.get().node.program.body.unshift(declaration); + } +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/read.js b/packages/@stylexjs/codemods/src/adapters/emotion/read.js new file mode 100644 index 000000000..b6200d153 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/read.js @@ -0,0 +1,167 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L3 — Read. Walks a detected style site's ObjectExpression into neutral + * declarations (the first seam hand-off). No StyleX knowledge. + * + * M1 scope: flat static string/number values only. Condition keys + * (':hover', '@media …', '&…'), nested objects, spreads, computed keys and + * non-literal values are blockers. A shorthand/longhand overlap inside one + * object (e.g. `margin` + `marginTop`) is refused until the M2 referee can + * arbitrate it — the exact bug class the old attempt shipped. + */ + +import type { Declaration } from '../../core/buildIR'; +import type { StyleSite } from './detect'; + +export type PlainStyleObject = { +[string]: string | number }; + +export type ReadSite = + | { + +ok: true, + +declarations: Array, + +cssObject: PlainStyleObject, + +nameHint: string, + } + | { +ok: false, +blocker: string }; + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +function literalValue(node: $FlowFixMe): string | number | null { + if ( + (node.type === 'Literal' || + node.type === 'StringLiteral' || + node.type === 'NumericLiteral') && + (typeof node.value === 'string' || typeof node.value === 'number') + ) { + return node.value; + } + if (node.type === 'UnaryExpression' && node.operator === '-') { + const inner = literalValue(node.argument); + if (typeof inner === 'number') { + return -inner; + } + } + return null; +} + +function propertyKey(node: $FlowFixMe): string | null { + if (node.type === 'Identifier') { + return node.name; + } + if ( + (node.type === 'Literal' || node.type === 'StringLiteral') && + typeof node.value === 'string' && + IDENTIFIER.test(node.value) + ) { + return node.value; + } + return null; +} + +/** + * Conservative shorthand/longhand overlap check: `marginTop` overlaps + * `margin` because it extends it with a capitalized segment. Errs toward + * false positives (`border` vs `borderRadius`) — a false positive skips a + * file (safe); the M2 referee replaces this with real priority data from + * `@stylexjs/shared`. + */ +function findShorthandOverlap(properties: Array): string | null { + for (const a of properties) { + for (const b of properties) { + if (b.length > a.length && b.startsWith(a) && /[A-Z]/.test(b[a.length])) { + return `'${a}' + '${b}'`; + } + } + } + return null; +} + +export function readSite(site: StyleSite): ReadSite { + const declarations: Array = []; + const cssObject: { [string]: string | number } = {}; + let label: string | null = null; + + for (const property of site.objectNode.properties) { + if (property.type !== 'Property' && property.type !== 'ObjectProperty') { + return { ok: false, blocker: 'spread in style object' }; + } + if (property.computed) { + return { ok: false, blocker: 'computed key in style object' }; + } + const key = propertyKey(property.key); + if (key == null) { + return { + ok: false, + blocker: + `style key ${describeKey(property.key)} is not convertible yet ` + + '(conditions land in M2, kebab-case keys in M3)', + }; + } + const value = literalValue(property.value); + if (value == null) { + return { + ok: false, + blocker: + `value of '${key}' is not a static string/number ` + + '(nested conditions land in M2; dynamic values in v1.1)', + }; + } + if (key === 'label' && typeof value === 'string') { + label = value; + continue; // debugging metadata, not a CSS declaration + } + if (cssObject[key] !== undefined) { + return { ok: false, blocker: `duplicate style key '${key}'` }; + } + declarations.push({ property: key, value }); + cssObject[key] = value; + } + + if (declarations.length === 0) { + return { ok: false, blocker: 'empty style object' }; + } + const overlap = findShorthandOverlap(declarations.map((d) => d.property)); + if (overlap != null) { + return { + ok: false, + blocker: `shorthand/longhand overlap (${overlap}) needs the M2 referee`, + }; + } + + return { + ok: true, + declarations, + cssObject, + nameHint: label ?? enclosingComponentName(site) ?? site.tagName, + }; +} + +function describeKey(node: $FlowFixMe): string { + return typeof node.value === 'string' ? `'${node.value}'` : `<${node.type}>`; +} + +function enclosingComponentName(site: StyleSite): string | null { + for (let path = site.attrPath; path != null; path = path.parent) { + const node = path.node; + if (node.type === 'FunctionDeclaration' && node.id?.name != null) { + return node.id.name; + } + if ( + node.type === 'VariableDeclarator' && + node.id?.type === 'Identifier' && + (node.init?.type === 'ArrowFunctionExpression' || + node.init?.type === 'FunctionExpression') + ) { + return node.id.name; + } + } + return null; +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/rewriteSites.js b/packages/@stylexjs/codemods/src/adapters/emotion/rewriteSites.js new file mode 100644 index 000000000..3c47762e4 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/rewriteSites.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L8 — Rewrite. Consumes the binding map (the second seam hand-off) and + * swaps each Emotion css prop for `{...stylex.props(styles.key)}` in that + * site's place. + */ + +import type { StyleSite } from './detect'; + +export function rewriteSite( + j: $FlowFixMe, + site: StyleSite, + stylesLocalName: string, + key: string, +): void { + const spread = j.jsxSpreadAttribute( + j.callExpression( + j.memberExpression(j.identifier('stylex'), j.identifier('props')), + [j.memberExpression(j.identifier(stylesLocalName), j.identifier(key))], + ), + ); + j(site.attrPath).replaceWith(spread); +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js new file mode 100644 index 000000000..e92d278f3 --- /dev/null +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -0,0 +1,165 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * The Emotion -> StyleX pipeline for one file: detect -> read (adapter), + * buildIR -> emit (core), rewrite -> registry -> imports (adapter). + * + * M1 policy (user-ratified): whole-file-or-nothing. A file is rewritten + * only when every style site is convertible; any blocker returns + * `skipped` with the reasons and the source untouched. TODO-comment + * flagging machinery lands in M5. + */ + +import { buildFileIR } from '../../core/buildIR'; +import { emitFileIR } from '../../core/emit'; +import { + parseSource, + printSource, + parserForFile, + styleToObjectAst, +} from '../../core/rewriter'; +import { + analyzeEmotionWiring, + removePragma, + removeCssImport, + insertStylexImport, +} from './imports'; +import { detectSites } from './detect'; +import { readSite } from './read'; +import type { PlainStyleObject } from './read'; +import { rewriteSite } from './rewriteSites'; + +export type TransformResult = + | { + +status: 'converted', + +code: string, + +sites: $ReadOnlyArray<{ +key: string, +cssObject: PlainStyleObject }>, + } + | { +status: 'skipped', +reasons: $ReadOnlyArray } + | { +status: 'unchanged' }; + +export function transformEmotionFile( + source: string, + filename: string = 'file.js', +): TransformResult { + // Cheap bail before parsing anything. + if (!source.includes('@emotion/react')) { + return { status: 'unchanged' }; + } + + const { j, root } = parseSource(source, { + parser: parserForFile(filename), + }); + + const wiring = analyzeEmotionWiring(j, root); + if (!wiring.hasPragma && wiring.cssLocalName == null) { + return { status: 'unchanged' }; + } + + const detection = detectSites(j, root, wiring.cssLocalName); + const blockers = [...wiring.blockers, ...detection.blockers]; + const reads = detection.sites.map(readSite); + for (const read of reads) { + if (!read.ok) { + blockers.push(read.blocker); + } + } + if (blockers.length > 0) { + return { status: 'skipped', reasons: blockers }; + } + if (detection.sites.length === 0) { + return { status: 'unchanged' }; + } + + // Adapter -> core: declarations in, create data + binding map out. + const groups = reads.map((read) => { + if (!read.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return { nameHint: read.nameHint, declarations: read.declarations }; + }); + const { rules, bindings } = emitFileIR(buildFileIR(groups)); + + // Core -> adapter: place the StyleX back into the file's idiom. + const stylesLocalName = pickStylesName(j, root); + detection.sites.forEach((site, i) => { + rewriteSite(j, site, stylesLocalName, bindings[i]); + }); + insertRegistry(j, root, detection.sites[0], stylesLocalName, rules); + insertStylexImport(j, root); + removeCssImport(j, root); + removePragma(j, root); + + return { + status: 'converted', + code: printSource({ j, root }), + sites: detection.sites.map((site, i) => { + const read = reads[i]; + if (!read.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return { key: bindings[i], cssObject: read.cssObject }; + }), + }; +} + +/** `styles`, or a numbered variant if the file already uses that name. */ +function pickStylesName(j: $FlowFixMe, root: $FlowFixMe): string { + const taken = (name: string) => root.find(j.Identifier, { name }).size() > 0; + let name = 'styles'; + for (let n = 2; taken(name); n++) { + name = `styles${n}`; + } + return name; +} + +/** + * Inserts `const = stylex.create({...})` directly above the + * top-level statement containing the first converted site (per the + * best-practices doc's registry placement). + */ +function insertRegistry( + j: $FlowFixMe, + root: $FlowFixMe, + firstSite: $FlowFixMe, + stylesLocalName: string, + rules: $ReadOnlyArray<{ + +key: string, + +style: { +[string]: string | number | $ReadOnlyArray }, + }>, +): void { + const createObject = j.objectExpression( + rules.map((rule) => + j.property( + 'init', + j.identifier(rule.key), + styleToObjectAst(j, rule.style), + ), + ), + ); + const declaration = j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier(stylesLocalName), + j.callExpression( + j.memberExpression(j.identifier('stylex'), j.identifier('create')), + [createObject], + ), + ), + ]); + + let statementPath = firstSite.attrPath; + while ( + statementPath.parent != null && + statementPath.parent.node.type !== 'Program' + ) { + statementPath = statementPath.parent; + } + j(statementPath).insertBefore(declaration); +} From dcb624c574fdfd3665ffd5c9dfda15c69a954719 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 12:17:42 -0400 Subject: [PATCH 07/32] =?UTF-8?q?codemods:=20semantic-diff=20gate=20?= =?UTF-8?q?=E2=80=94=20normalize=20comma-whitespace=20in=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../@stylexjs/codemods/src/core/gates/semanticDiff.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js index ad99f6080..409188012 100644 --- a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js +++ b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js @@ -73,7 +73,13 @@ function normalizeAtRule(rule: string): string { } function normalizeValue(value: string): string { - return value.trim().replace(/\s+/g, ' '); + // Comma-whitespace is canonicalized because CSS treats + // `rgb(10, 20, 30)` and `rgb(10,20,30)` as the same value — and the + // StyleX compiler normalizes to the latter. + return value + .trim() + .replace(/\s+/g, ' ') + .replace(/\s*,\s*/g, ','); } function normalizeCondition(raw: string): string { From a8851f946fbf58f3affd31b810860eaaa11c18e3 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 12:17:53 -0400 Subject: [PATCH 08/32] codemods: wire transform into fixture harness; un-skip checks 1 & 4; 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 --- .../emotion/css-call-form/expected.js | 13 +++ .../emotion/css-call-form/input.js | 7 ++ .../emotion/skip-component-css/expected.js | 7 ++ .../emotion/skip-component-css/input.js | 7 ++ .../emotion/skip-existing-stylex/expected.js | 17 +++ .../emotion/skip-existing-stylex/input.js | 17 +++ .../emotion/skip-pseudo/expected.js | 10 ++ .../__fixtures__/emotion/skip-pseudo/input.js | 10 ++ .../skip-shorthand-conflict/expected.js | 6 + .../emotion/skip-shorthand-conflict/input.js | 6 + .../emotion/skip-template-literal/expected.js | 11 ++ .../emotion/skip-template-literal/input.js | 11 ++ .../emotion/static-flat-color/expected.js | 5 +- .../emotion/static-values/expected.js | 16 +++ .../emotion/static-values/input.js | 18 +++ .../emotion/two-sites/expected.js | 21 ++++ .../__fixtures__/emotion/two-sites/input.js | 10 ++ .../__tests__/emotion-fixtures-test.js | 106 ++++++++++++------ 18 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/static-values/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/static-values/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/expected.js new file mode 100644 index 000000000..245fe7065 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/expected.js @@ -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

Alert

; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/input.js new file mode 100644 index 000000000..83ec208ce --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/css-call-form/input.js @@ -0,0 +1,7 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { css } from '@emotion/react'; + +export default function Alert() { + return

Alert

; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.js new file mode 100644 index 000000000..856de0164 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.js @@ -0,0 +1,7 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { Button } from './Button'; + +export default function Toolbar() { + return ; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/input.js new file mode 100644 index 000000000..856de0164 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/input.js @@ -0,0 +1,7 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { Button } from './Button'; + +export default function Toolbar() { + return ; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.js new file mode 100644 index 000000000..56dc390c8 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.js @@ -0,0 +1,17 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + card: { + padding: '8px', + }, +}); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/input.js new file mode 100644 index 000000000..56dc390c8 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/input.js @@ -0,0 +1,17 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + card: { + padding: '8px', + }, +}); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/expected.js new file mode 100644 index 000000000..c63e63fa1 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/expected.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Link() { + return ( + + Link + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/input.js new file mode 100644 index 000000000..c63e63fa1 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/input.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Link() { + return ( + + Link + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.js new file mode 100644 index 000000000..fc139e9f0 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Box() { + return
Box
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/input.js new file mode 100644 index 000000000..fc139e9f0 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Box() { + return
Box
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.js new file mode 100644 index 000000000..3ed42dc92 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.js @@ -0,0 +1,11 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { css } from '@emotion/react'; + +const fancy = css` + color: red; +`; + +export default function Fancy() { + return
Fancy
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/input.js new file mode 100644 index 000000000..3ed42dc92 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/input.js @@ -0,0 +1,11 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { css } from '@emotion/react'; + +const fancy = css` + color: red; +`; + +export default function Fancy() { + return
Fancy
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js index 1b8c3d22f..29ea198b7 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-flat-color/expected.js @@ -1,8 +1,11 @@ import * as React from 'react'; + import * as stylex from '@stylexjs/stylex'; const styles = stylex.create({ - badge: { color: 'red' }, + badge: { + color: 'red', + }, }); export default function Badge() { diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/expected.js new file mode 100644 index 000000000..36010c2f4 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + fancyCard: { + color: 'rgb(10, 20, 30)', + fontSize: 16, + lineHeight: 1.5, + marginTop: -4, + }, +}); + +export default function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/input.js new file mode 100644 index 000000000..f0f4c1463 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/static-values/input.js @@ -0,0 +1,18 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Card() { + return ( +
+ Card +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/expected.js new file mode 100644 index 000000000..92d453733 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/expected.js @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + badge: { + color: 'darkgreen', + }, + + card: { + padding: '16px', + }, +}); + +export function Badge() { + return Badge; +} + +export function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/input.js new file mode 100644 index 000000000..868975bd5 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/two-sites/input.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export function Badge() { + return Badge; +} + +export function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js index a25d09fad..3cb1f4b25 100644 --- a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js +++ b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js @@ -15,24 +15,26 @@ * 2. expected compiles through @stylexjs/babel-plugin; * 3. expected passes @stylexjs/eslint-plugin at error, zero messages; * 4. the net CSS of input and expected is semantically identical - * (minus the sanctioned allowlist). + * (Emotion's own serializer vs the babel-plugin metadata, minus the + * sanctioned allowlist). Skip-fixtures (expected === input) assert + * that the transform really did refuse. * * Set UPDATE_STYLEX_CODEMOD_FIXTURES=1 to regenerate expected files when a * change is intentional. - * - * M0 status: no transform exists yet, so checks 1 and 4 (which need the - * transform / the adapter's reader) are pending and explicitly skipped; - * checks 2 and 3 run for real. M1 wires `transform` below and un-skips. */ import * as fs from 'fs'; +import { serializeStyles } from '@emotion/serialize'; import { compileGate } from '../src/core/gates/compile'; import { lintGate } from '../src/core/gates/lint'; +import { + semanticDiffGate, + netCssFromSerializedCss, + netCssFromStylexMetadata, +} from '../src/core/gates/semanticDiff'; +import { transformEmotionFile } from '../src/adapters/emotion/transform'; import { loadFixtures, formatWithPrettier } from './utils/harness'; -// M1: replace with the real emotion transform (input source -> output source). -const transform: ((source: string, filename: string) => string) | null = null; - const UPDATE = process.env.UPDATE_STYLEX_CODEMOD_FIXTURES === '1'; const fixtures = loadFixtures('emotion'); @@ -50,17 +52,12 @@ test('prettier normalization is available and idempotent', () => { describe.each(fixtures.map((f) => [f.name, f]))( 'fixture: %s', (_name, fixture) => { - const testIfTransform = transform == null ? test.skip : test; + const result = transformEmotionFile(fixture.input, fixture.inputPath); + const output = result.status === 'converted' ? result.code : fixture.input; - // Check 1 — pending M1 (needs the transform). - testIfTransform('transform(input) matches expected byte-exactly', () => { - if (transform == null) { - throw new Error('unreachable'); - } - const actual = formatWithPrettier( - transform(fixture.input, fixture.inputPath), - fixture.expectedPath, - ); + // Check 1 — byte-exact against expected, formatting-insensitive. + test('transform(input) matches expected byte-exactly', () => { + const actual = formatWithPrettier(output, fixture.expectedPath); if (UPDATE) { fs.writeFileSync(fixture.expectedPath, actual); } @@ -69,35 +66,72 @@ describe.each(fixtures.map((f) => [f.name, f]))( ); }); - // Check 2 — live from M0. + // Check 2. test('expected compiles through @stylexjs/babel-plugin', () => { - const result = compileGate(fixture.expected, { + const compiled = compileGate(fixture.expected, { filename: fixture.expectedPath, }); - if (!result.ok) { - throw new Error(result.errors.join('\n')); + if (!compiled.ok) { + throw new Error(compiled.errors.join('\n')); } - expect(result.ok).toBe(true); + expect(compiled.ok).toBe(true); }); - // Check 3 — live from M0. + // Check 3. test('expected passes @stylexjs/eslint-plugin at error with zero messages', () => { - const result = lintGate(fixture.expected, { + const linted = lintGate(fixture.expected, { filename: fixture.expectedPath, }); - if (!result.ok) { - throw new Error(JSON.stringify(result.messages, null, 2)); + if (!linted.ok) { + throw new Error(JSON.stringify(linted.messages, null, 2)); } - expect(result.ok).toBe(true); + expect(linted.ok).toBe(true); }); - // Check 4 — pending M1 (extracting the input's style objects is the - // adapter reader's job; the gate itself is proven in gates-test.js). - testIfTransform( - 'net CSS of input and expected is semantically identical', - () => { - throw new Error('wired in M1 with the emotion adapter reader'); - }, - ); + // Check 4. + test('net CSS of input and expected is semantically identical', () => { + if (result.status !== 'converted') { + // A skip-fixture: the transform must have refused (loudly, with + // reasons) and left the file byte-identical. + expect(fixture.expected).toEqual(fixture.input); + if (result.status === 'skipped') { + expect(result.reasons.length).toBeGreaterThan(0); + } + return; + } + // Before: Emotion's own serializer over each converted object. + // (Fixture-design constraint: sites must not restate the same + // property+conditions with different values, or the union is lossy.) + const before: { [string]: $FlowFixMe } = {}; + for (const site of result.sites) { + const net = netCssFromSerializedCss( + serializeStyles([site.cssObject]).styles, + ); + for (const coordinate of Object.keys(net)) { + if ( + before[coordinate] != null && + before[coordinate].value !== net[coordinate].value + ) { + throw new Error( + `fixture restates '${coordinate}' with different values across sites`, + ); + } + before[coordinate] = net[coordinate]; + } + } + // After: the real babel-plugin metadata for the converted output. + const compiled = compileGate(output, { filename: fixture.expectedPath }); + if (!compiled.ok) { + throw new Error(compiled.errors.join('\n')); + } + const diff = semanticDiffGate( + before, + netCssFromStylexMetadata(compiled.metadata), + ); + if (!diff.ok) { + throw new Error(JSON.stringify(diff.diffs, null, 2)); + } + expect(diff.ok).toBe(true); + }); }, ); From 9af4a55cbf1c4a8104e3f0e2a1c7db5515234a75 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 12:18:14 -0400 Subject: [PATCH 09/32] =?UTF-8?q?codemods:=20real=20IR-completeness=20roun?= =?UTF-8?q?d-trip=20=E2=80=94=20stylexToIR=20reader=20+=20measured=20cover?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/ir-completeness-test.js | 92 ++++++++++++++++--- .../codemods/src/testing/stylexToIR.js | 64 +++++++++++-- 2 files changed, 135 insertions(+), 21 deletions(-) diff --git a/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js index 6118011a0..e68fa8adb 100644 --- a/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js +++ b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js @@ -8,23 +8,30 @@ */ /** - * IR-completeness harness (M0 stub). + * IR-completeness harness. * * Completeness is MEASURED, not asserted: every valid StyleX style object - * in the corpus is read into the IR; whatever cannot be represented is a - * concrete, safe coverage gap (in the real pipeline it would be flagged, - * never emitted incorrectly). The reader lands with the M1 emitter — until then - * every entry counts as uncovered and the reported coverage is 0%. + * in the corpus is read into the IR, re-emitted, compiled through the real + * babel-plugin, and required to be semantically identical to the original + * (empty allowlist — same object, so zero sanctioned drift). Whatever the + * reader cannot represent is a concrete, SAFE coverage gap (in the real + * pipeline it would be flagged, never emitted incorrectly). * - * M1+: replace the seed corpus with extraction from StyleX's own valid - * fixtures (`@stylexjs/babel-plugin` / `@stylexjs/eslint-plugin` tests), - * and round-trip each covered entry (IR -> emit -> compile + semantic-diff). + * M1+: grow the corpus toward extraction from StyleX's own valid fixtures + * (`@stylexjs/babel-plugin` / `@stylexjs/eslint-plugin` tests). */ import { stylexObjectToIR, IRCoverageGapError, } from '../src/testing/stylexToIR'; +import { emitFileIR } from '../src/core/emit'; +import type { EmittedStyle, EmittedValue } from '../src/core/emit'; +import { compileGate } from '../src/core/gates/compile'; +import { + semanticDiffGate, + netCssFromStylexMetadata, +} from '../src/core/gates/semanticDiff'; const SEED_CORPUS: $ReadOnlyArray<[string, mixed]> = [ ['flat static styles', { color: 'red', fontSize: 16 }], @@ -44,24 +51,81 @@ const SEED_CORPUS: $ReadOnlyArray<[string, mixed]> = [ ['fallback array (firstThatWorks)', { position: ['sticky', 'fixed'] }], ]; -test('every corpus entry is either covered by the IR or a typed coverage gap', () => { - let covered = 0; +function valueToSource(value: EmittedValue | mixed): string { + if (typeof value === 'string') { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + } + if (typeof value === 'number') { + return String(value); + } + if (Array.isArray(value)) { + return `[${value.map(valueToSource).join(', ')}]`; + } + throw new Error(`unstringifiable value: ${String(value)}`); +} + +function styleToSource(style: EmittedStyle | mixed): string { + if (style == null || typeof style !== 'object') { + throw new Error('unstringifiable style object'); + } + const entries = Object.keys(style).map( + (property) => `${property}: ${valueToSource(style[property])}`, + ); + return `{ ${entries.join(', ')} }`; +} + +function netCssOfStyleObject(style: EmittedStyle | mixed) { + const source = [ + "import * as stylex from '@stylexjs/stylex';", + `export const styles = stylex.create({ k: ${styleToSource(style)} });`, + '', + ].join('\n'); + const compiled = compileGate(source); + if (!compiled.ok) { + throw new Error( + `corpus entry does not compile:\n${compiled.errors.join('\n')}`, + ); + } + return netCssFromStylexMetadata(compiled.metadata); +} + +test('every corpus entry round-trips through the IR or is a typed coverage gap', () => { + const covered: Array = []; const gaps: Array = []; for (const [name, styleObject] of SEED_CORPUS) { + let rule; try { - stylexObjectToIR(name, styleObject); - covered += 1; + rule = stylexObjectToIR(name, styleObject); } catch (error) { if (!(error instanceof IRCoverageGapError)) { throw error; // a real bug, not a coverage gap } gaps.push(name); + continue; + } + // Round-trip: IR -> emit -> compile, and compare against the original + // object compiled directly. Empty allowlist: any drift is a failure. + const { rules } = emitFileIR({ rules: [rule], keyframes: [] }); + const diff = semanticDiffGate( + netCssOfStyleObject(styleObject), + netCssOfStyleObject(rules[0].style), + { allowlist: [] }, + ); + if (!diff.ok) { + throw new Error( + `round-trip drift for '${name}': ${JSON.stringify(diff.diffs)}`, + ); } + covered.push(name); } - expect(covered + gaps.length).toBe(SEED_CORPUS.length); + expect(covered.length + gaps.length).toBe(SEED_CORPUS.length); + expect(covered).toEqual([ + 'flat static styles', + 'fallback array (firstThatWorks)', + ]); // eslint-disable-next-line no-console console.info( - `[ir-completeness] ${covered}/${SEED_CORPUS.length} corpus entries covered` + + `[ir-completeness] ${covered.length}/${SEED_CORPUS.length} corpus entries covered` + (gaps.length > 0 ? ` — gaps: ${gaps.join(', ')}` : ''), ); }); diff --git a/packages/@stylexjs/codemods/src/testing/stylexToIR.js b/packages/@stylexjs/codemods/src/testing/stylexToIR.js index a80eee274..5c9e4a717 100644 --- a/packages/@stylexjs/codemods/src/testing/stylexToIR.js +++ b/packages/@stylexjs/codemods/src/testing/stylexToIR.js @@ -16,11 +16,12 @@ * coverage percentage. An IR gap found here is SAFE — in the real pipeline * the same construct would be flagged, never emitted incorrectly. * - * Not implemented until M1 (it needs the emitter to round-trip against); - * the harness counts every corpus entry as uncovered until then. + * M1 coverage: flat static values and fallback arrays. Condition objects + * (pseudo/at-rule keys or condition-in-value objects) are M2 gaps; null + * values and keyframes are M3 gaps. */ -import type { StyleRule } from '../core/ir'; +import type { Atom, StyleRule } from '../core/ir'; export class IRCoverageGapError extends Error { constructor(message: string) { @@ -29,9 +30,58 @@ export class IRCoverageGapError extends Error { } } -// eslint-disable-next-line no-unused-vars +// Builds a properly-typed (string | number)[] from an untyped array, or +// null if any entry is not one of those two types. A plain `.every()` +// predicate does not refine an `Array` to +// `Array` for Flow, so this does the narrowing by hand. +function toStaticValueArray( + raw: $ReadOnlyArray, +): Array | null { + const values: Array = []; + for (const entry of raw) { + if (typeof entry !== 'string' && typeof entry !== 'number') { + return null; + } + values.push(entry); + } + return values; +} + export function stylexObjectToIR(name: string, styleObject: mixed): StyleRule { - throw new IRCoverageGapError( - 'stylexObjectToIR is not implemented yet (lands with the M1 emitter)', - ); + if ( + styleObject == null || + typeof styleObject !== 'object' || + Array.isArray(styleObject) + ) { + throw new IRCoverageGapError(`'${name}': not a style object`); + } + const atoms: Array = []; + for (const property of Object.keys(styleObject)) { + const raw = styleObject[property]; + if (typeof raw === 'string' || typeof raw === 'number') { + atoms.push({ + property, + conditions: [], + value: { kind: 'static', value: raw }, + }); + } else if (Array.isArray(raw) && raw.length > 0) { + const values = toStaticValueArray(raw); + if (values == null) { + throw new IRCoverageGapError( + `'${name}.${property}': fallback array contains a non-static entry`, + ); + } + atoms.push({ + property, + conditions: [], + value: { kind: 'first-that-works', values }, + }); + } else { + throw new IRCoverageGapError( + `'${name}.${property}': value form not representable yet ` + + '(conditions land in M2, null/keyframes in M3)', + ); + } + } + return { name, atoms }; } From ed567b5ac3f578863bfc4dca4749bf7be4c68f5d Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 14:14:56 -0400 Subject: [PATCH 10/32] =?UTF-8?q?codemods:=20referee=20(L5)=20=E2=80=94=20?= =?UTF-8?q?the=20two-order=20agreement=20check=20for=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../{skip-pseudo => hover-pseudo}/expected.js | 0 .../{skip-pseudo => hover-pseudo}/input.js | 0 .../codemods/__tests__/referee-test.js | 150 ++++++++++++++++++ .../@stylexjs/codemods/src/core/referee.js | 150 ++++++++++++++++++ 4 files changed, 300 insertions(+) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-pseudo => hover-pseudo}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-pseudo => hover-pseudo}/input.js (100%) create mode 100644 packages/@stylexjs/codemods/__tests__/referee-test.js create mode 100644 packages/@stylexjs/codemods/src/core/referee.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-pseudo/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/input.js diff --git a/packages/@stylexjs/codemods/__tests__/referee-test.js b/packages/@stylexjs/codemods/__tests__/referee-test.js new file mode 100644 index 000000000..05be7a7e1 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/referee-test.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Referee unit tests — hand-written IR, no Emotion. The referee is the + * silent-wrong-cascade guard, so it gets the heaviest scrutiny: every + * shorthand of the agreement/disagreement matrix. + */ + +import type { Atom, Condition, StyleRule } from '../src/core/ir'; +import { checkRule } from '../src/core/referee'; + +const base = (value: string): Atom => ({ + property: 'color', + conditions: [], + value: { kind: 'static', value }, +}); +const pc = (name: string, value: string): Atom => ({ + property: 'color', + conditions: [{ kind: 'pseudo-class', name }], + value: { kind: 'static', value }, +}); +const pe = (name: string, value: string): Atom => ({ + property: 'color', + conditions: [{ kind: 'pseudo-element', name }], + value: { kind: 'static', value }, +}); +const media = (rule: string, value: string): Atom => ({ + property: 'color', + conditions: [{ kind: 'at-rule', rule }], + value: { kind: 'static', value }, +}); + +const rule = (atoms: Array): StyleRule => ({ name: 'x', atoms }); +const MEDIA = '@media (min-width: 600px)'; + +describe('checkRule — agreement (converts)', () => { + test('base then :hover — cascade and priority agree', () => { + expect(checkRule(rule([base('red'), pc(':hover', 'blue')])).ok).toBe(true); + }); + + test(':hover then :focus (source order matches ascending priority)', () => { + expect( + checkRule(rule([pc(':hover', 'blue'), pc(':focus', 'green')])).ok, + ).toBe(true); + }); + + test(':hover then base — base wins by cascade despite later source (lower specificity)', () => { + // Reversed source order, but :hover has higher specificity AND higher + // priority, so both systems agree :hover wins. + expect(checkRule(rule([pc(':hover', 'blue'), base('red')])).ok).toBe(true); + }); + + test('pseudo-element and base never compete (different boxes)', () => { + expect(checkRule(rule([base('red'), pe('::before', 'gray')])).ok).toBe( + true, + ); + }); + + test(':hover and ::before mix does not false-refuse (partitioned by target)', () => { + // Cascade orders ::before before :hover (b dominates), priority orders + // :hover before ::before — but they are different boxes, so partitioning + // must prevent a spurious conflict. + expect( + checkRule(rule([pc(':hover', 'blue'), pe('::before', 'gray')])).ok, + ).toBe(true); + }); + + test('base then media (source order matches priority)', () => { + expect(checkRule(rule([base('red'), media(MEDIA, 'green')])).ok).toBe(true); + }); +}); + +describe('checkRule — disagreement (refuses)', () => { + test(':focus then :hover — priority picks focus, source order picks hover', () => { + const result = checkRule( + rule([pc(':focus', 'green'), pc(':hover', 'blue')]), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.conflicts[0]).toMatch(/disagree/); + } + }); + + test('media then base — priority picks media, cascade picks base (later, equal specificity)', () => { + const result = checkRule(rule([media(MEDIA, 'green'), base('red')])); + expect(result.ok).toBe(false); + }); +}); + +describe('checkRule — dedup', () => { + test('same-coordinate duplicates collapse to the last in source order', () => { + const result = checkRule( + rule([pc(':hover', 'blue'), pc(':hover', 'navy')]), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.rule.atoms).toHaveLength(1); + expect(result.rule.atoms[0].value).toEqual({ + kind: 'static', + value: 'navy', + }); + } + }); + + test('distinct properties are refereed independently', () => { + const hover: Condition = { kind: 'pseudo-class', name: ':hover' }; + const focus: Condition = { kind: 'pseudo-class', name: ':focus' }; + // color agrees (hover→focus), but backgroundColor disagrees (focus→hover). + const mixed: StyleRule = { + name: 'x', + atoms: [ + { + property: 'color', + conditions: [hover], + value: { kind: 'static', value: 'blue' }, + }, + { + property: 'color', + conditions: [focus], + value: { kind: 'static', value: 'green' }, + }, + { + property: 'backgroundColor', + conditions: [focus], + value: { kind: 'static', value: 'white' }, + }, + { + property: 'backgroundColor', + conditions: [hover], + value: { kind: 'static', value: 'gray' }, + }, + ], + }; + const result = checkRule(mixed); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.conflicts.some((c) => c.includes('backgroundColor'))).toBe( + true, + ); + expect(result.conflicts.some((c) => c.includes("'color'"))).toBe(false); + } + }); +}); diff --git a/packages/@stylexjs/codemods/src/core/referee.js b/packages/@stylexjs/codemods/src/core/referee.js new file mode 100644 index 000000000..0d59da427 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/referee.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L5 — Referee. The safety check that makes conditional conversion trustworthy. + * + * Emotion emits plain CSS; the browser resolves it by the cascade + * (specificity, then source order). StyleX resolves atomic classes by a + * fixed priority number per condition (imported here from + * `@stylexjs/shared`, never reinvented). StyleX's priorities are DESIGNED to + * replicate the cascade — so the two agree automatically whenever they + * differ in specificity. The one place they can DISAGREE is among conditions + * of EQUAL specificity (e.g. two pseudo-classes both active), where CSS uses + * source order but StyleX uses its fixed priority. + * + * So the rule is: for each property, within each pseudo-element target (a + * pseudo-element styles a different box, so it never competes with the base + * element), the order induced by the cascade must match the order induced by + * StyleX priority. If they match for every property, the conversion renders + * identically under every combination of active conditions. If any property + * disagrees, we REFUSE — never emit a confident-but-wrong cascade. + * + * Two referees "agree" is proven post-hoc by the semantic-diff gate for the + * per-coordinate values; this referee covers the thing that gate cannot see: + * which declaration wins when several are simultaneously active. + */ + +import { getPriority } from '@stylexjs/shared'; +import type { Atom, Condition, StyleRule } from './ir'; +import { conditionKey } from './ir'; + +export type RefereeResult = + | { +ok: true, +rule: StyleRule } + | { +ok: false, +conflicts: $ReadOnlyArray }; + +/** Sum of StyleX condition priorities for an atom (property base cancels + * within a property, so it is intentionally omitted). */ +function conditionPriority(conditions: $ReadOnlyArray): number { + let total = 0; + for (const condition of conditions) { + total += getPriority(conditionKey(condition)); + } + return total; +} + +/** Count of pseudo-classes — the only part of a condition stack that + * changes CSS specificity among competing (same-box) atoms. */ +function pseudoClassCount(conditions: $ReadOnlyArray): number { + return conditions.filter((c) => c.kind === 'pseudo-class').length; +} + +/** The pseudo-element a condition stack targets, or '' for the base box. + * Atoms with different targets never compete. */ +function pseudoElementTarget(conditions: $ReadOnlyArray): string { + return conditions + .filter((c) => c.kind === 'pseudo-element') + .map(conditionKey) + .sort() + .join(''); +} + +function coordinate(atom: Atom): string { + return atom.conditions.map(conditionKey).slice().sort().join('&&'); +} + +/** + * Checks one rule. On success returns the rule with same-coordinate + * duplicates collapsed (last-in-source wins, matching both Emotion and + * StyleX). On conflict returns the offending property coordinates. + */ +export function checkRule(rule: StyleRule): RefereeResult { + const conflicts: Array = []; + + // Group atoms by property, preserving source order (array order). + const byProperty: Map< + string, + Array<{ +atom: Atom, +order: number }>, + > = new Map(); + rule.atoms.forEach((atom, order) => { + const list = byProperty.get(atom.property) ?? []; + list.push({ atom, order }); + byProperty.set(atom.property, list); + }); + + const keptAtoms: Array = []; + + for (const [property, entries] of byProperty) { + // Collapse same-coordinate duplicates: keep the last in source order. + const lastByCoord: Map = new Map(); + for (const entry of entries) { + lastByCoord.set(coordinate(entry.atom), entry); + } + const deduped = [...lastByCoord.values()]; + + // Partition by pseudo-element target; each box is refereed independently. + const byTarget: Map< + string, + Array<{ +atom: Atom, +order: number }>, + > = new Map(); + for (const entry of deduped) { + const target = pseudoElementTarget(entry.atom.conditions); + const list = byTarget.get(target) ?? []; + list.push(entry); + byTarget.set(target, list); + } + + for (const [, group] of byTarget) { + // Cascade order: specificity (pseudo-class count) asc, then source order. + const cascadeOrder = [...group].sort((a, b) => { + const spec = + pseudoClassCount(a.atom.conditions) - + pseudoClassCount(b.atom.conditions); + return spec !== 0 ? spec : a.order - b.order; + }); + // StyleX order: condition priority asc, then source order for ties. + const stylexOrder = [...group].sort((a, b) => { + const pri = + conditionPriority(a.atom.conditions) - + conditionPriority(b.atom.conditions); + return pri !== 0 ? pri : a.order - b.order; + }); + // The two orderings must be identical, or some active-condition combo + // renders differently. + for (let i = 0; i < cascadeOrder.length; i++) { + if (cascadeOrder[i].atom !== stylexOrder[i].atom) { + conflicts.push( + `'${property}': Emotion source-order and StyleX priority disagree ` + + 'on which conditional value wins when several are active ' + + `(${group.map((g) => coordinate(g.atom) || 'base').join(' vs ')})`, + ); + break; + } + } + for (const entry of group) { + keptAtoms.push(entry.atom); + } + } + } + + if (conflicts.length > 0) { + return { ok: false, conflicts }; + } + return { ok: true, rule: { name: rule.name, atoms: keptAtoms } }; +} From a467ce95f95fc0d2cb40f88cd1608195df123c43 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 14:15:10 -0400 Subject: [PATCH 11/32] codemods: the flip + hover-guard in emit (L4/L7) 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 --- .../@stylexjs/codemods/__tests__/emit-test.js | 121 +++++++++--- .../@stylexjs/codemods/src/core/buildIR.js | 14 +- packages/@stylexjs/codemods/src/core/emit.js | 182 +++++++++++++++--- .../@stylexjs/codemods/src/core/rewriter.js | 38 +++- 4 files changed, 289 insertions(+), 66 deletions(-) diff --git a/packages/@stylexjs/codemods/__tests__/emit-test.js b/packages/@stylexjs/codemods/__tests__/emit-test.js index 84b972782..ff1a63939 100644 --- a/packages/@stylexjs/codemods/__tests__/emit-test.js +++ b/packages/@stylexjs/codemods/__tests__/emit-test.js @@ -14,7 +14,7 @@ * output). */ -import type { FileIR } from '../src/core/ir'; +import type { Atom, FileIR } from '../src/core/ir'; import { buildFileIR } from '../src/core/buildIR'; import { emitFileIR, sanitizeKey, EmitError } from '../src/core/emit'; @@ -82,26 +82,7 @@ describe('emitFileIR', () => { expect(sanitizeKey('default')).toBe('styles'); }); - test('REFUSES conditions (M2) rather than emitting incorrectly', () => { - const ir: FileIR = { - rules: [ - { - name: 'badge', - atoms: [ - { - property: 'color', - conditions: [{ kind: 'pseudo-class', name: ':hover' }], - value: { kind: 'static', value: 'blue' }, - }, - ], - }, - ], - keyframes: [], - }; - expect(() => emitFileIR(ir)).toThrow(EmitError); - }); - - test('REFUSES duplicate properties within a rule', () => { + test('REFUSES a duplicate unconditional base declaration', () => { const ir = irOf([ [ 'badge', @@ -115,14 +96,104 @@ describe('emitFileIR', () => { }); }); +describe('emitFileIR — conditions (the flip)', () => { + const cond = ( + name: string, + kind: 'pseudo-class' | 'pseudo-element', + value: string, + ): Atom => ({ + property: 'color', + conditions: [ + kind === 'pseudo-class' + ? { kind: 'pseudo-class', name } + : { kind: 'pseudo-element', name }, + ], + value: { kind: 'static', value }, + }); + const baseAtom = (value: string): Atom => ({ + property: 'color', + conditions: [], + value: { kind: 'static', value }, + }); + const ruleOf = (atoms: Array): FileIR => ({ + rules: [{ name: 'badge', atoms }], + keyframes: [], + }); + + test('base + :hover nests property-grouped, hover-guarded by default', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond(':hover', 'pseudo-class', 'blue')]), + ); + expect(rules[0].style).toEqual({ + color: { + default: 'red', + '@media (hover: hover)': { default: null, ':hover': 'blue' }, + }, + }); + }); + + test('hover-guard can be disabled', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond(':hover', 'pseudo-class', 'blue')]), + { hoverGuard: false }, + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', ':hover': 'blue' }, + }); + }); + + test(':focus is not hover-guarded', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond(':focus', 'pseudo-class', 'green')]), + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', ':focus': 'green' }, + }); + }); + + test('a condition with no base gets default: null', () => { + const { rules } = emitFileIR( + ruleOf([cond(':focus', 'pseudo-class', 'green')]), + ); + expect(rules[0].style).toEqual({ + color: { default: null, ':focus': 'green' }, + }); + }); + + test('media condition nests as an at-rule object', () => { + const { rules } = emitFileIR( + ruleOf([ + baseAtom('red'), + { + property: 'color', + conditions: [{ kind: 'at-rule', rule: '@media (min-width: 600px)' }], + value: { kind: 'static', value: 'green' }, + }, + ]), + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', '@media (min-width: 600px)': 'green' }, + }); + }); + + test('pseudo-element nests (different box, not hover-guarded)', () => { + const { rules } = emitFileIR( + ruleOf([baseAtom('red'), cond('::before', 'pseudo-element', 'gray')]), + ); + expect(rules[0].style).toEqual({ + color: { default: 'red', '::before': 'gray' }, + }); + }); +}); + describe('buildFileIR', () => { test('flat declarations become zero-condition static atoms', () => { const ir = buildFileIR([ { nameHint: 'badge', declarations: [ - { property: 'color', value: 'red' }, - { property: 'fontSize', value: 16 }, + { property: 'color', value: { kind: 'static', value: 'red' } }, + { property: 'fontSize', value: { kind: 'static', value: 16 } }, ], }, ]); @@ -153,7 +224,9 @@ describe('buildFileIR', () => { buildFileIR([ { nameHint: 'Badge', - declarations: [{ property: 'color', value: 'red' }], + declarations: [ + { property: 'color', value: { kind: 'static', value: 'red' } }, + ], }, ]), ); diff --git a/packages/@stylexjs/codemods/src/core/buildIR.js b/packages/@stylexjs/codemods/src/core/buildIR.js index 6b6d0c205..c154029e9 100644 --- a/packages/@stylexjs/codemods/src/core/buildIR.js +++ b/packages/@stylexjs/codemods/src/core/buildIR.js @@ -17,15 +17,17 @@ * home so the seam does not move. */ -import type { Atom, FileIR, StyleRule } from './ir'; +import type { Atom, Condition, FileIR, StyleRule, Value } from './ir'; /** - * One style declaration as handed over by an adapter: no conditions yet - * (M1), no StyleX knowledge, no AST nodes. + * One style declaration as handed over by an adapter: a property, a value, + * and the self-targeting conditions it applies under (empty for a flat + * declaration). No StyleX knowledge, no AST nodes. */ export type Declaration = { +property: string, - +value: string | number, + +value: Value, + +conditions?: $ReadOnlyArray, }; /** @@ -42,8 +44,8 @@ export function buildFileIR(groups: $ReadOnlyArray): FileIR { const rules: Array = groups.map((group) => { const atoms: Array = group.declarations.map((declaration) => ({ property: declaration.property, - conditions: [], - value: { kind: 'static', value: declaration.value }, + conditions: declaration.conditions ?? [], + value: declaration.value, })); return { name: group.nameHint, atoms }; }); diff --git a/packages/@stylexjs/codemods/src/core/emit.js b/packages/@stylexjs/codemods/src/core/emit.js index 7fc62104a..8624a7ec1 100644 --- a/packages/@stylexjs/codemods/src/core/emit.js +++ b/packages/@stylexjs/codemods/src/core/emit.js @@ -15,18 +15,29 @@ * Deliberately AST-free: emit produces plain data; the adapter (which owns * the file's AST) renders it. This keeps `core/` blind to parser nodes. * + * "The flip" materializes here: atoms (property × condition-stack × value) + * are grouped BY PROPERTY with conditions nested inside — StyleX's required + * property-grouped shape, the inverse of Emotion's selector-grouped input. + * * Naming (M1 minimal policy, superseded wholesale in M4): the adapter's * nameHint (label > enclosing component) is sanitized to a camelCase * identifier; collisions get a numeric suffix starting at 2. */ -import type { FileIR, StyleRule, Value } from './ir'; +import type { Atom, Condition, FileIR, StyleRule, Value } from './ir'; +import { conditionKey } from './ir'; /** - * A value in a `stylex.create` entry: a static value, or a fallback array - * (StyleX's shorthand for `firstThatWorks`). + * A value in a `stylex.create` entry: a static value, a fallback array + * (StyleX's `firstThatWorks`), or a nested condition object. */ -export type EmittedValue = string | number | $ReadOnlyArray; +export type EmittedValue = + | string + | number + | null + | $ReadOnlyArray + | EmittedConditions; +export type EmittedConditions = { +[condition: string]: EmittedValue }; /** A `stylex.create` entry as plain data: property -> value. */ export type EmittedStyle = { +[property: string]: EmittedValue }; @@ -42,6 +53,11 @@ export type EmitResult = { +bindings: $ReadOnlyArray, }; +export type EmitOptions = { + /** Wrap `:hover` in `@media (hover: hover)` (default true). */ + +hoverGuard?: boolean, +}; + export class EmitError extends Error { constructor(message: string) { super(`[stylex-codemod emit] ${message}`); @@ -51,6 +67,7 @@ export class EmitError extends Error { const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; const RESERVED = new Set(['default', 'delete', 'do', 'in', 'new', 'var']); +const HOVER_GUARD = '@media (hover: hover)'; /** Sanitizes an adapter name hint into a usable create key. */ export function sanitizeKey(hint: string): string { @@ -64,45 +81,154 @@ export function sanitizeKey(hint: string): string { return IDENTIFIER.test(key) && !RESERVED.has(key) ? key : 'styles'; } -function emitValue(value: Value, rule: StyleRule): EmittedValue { +function staticValue(value: Value, rule: StyleRule): string | number | null { if (value.kind === 'first-that-works') { - return value.values; + // A fallback array is itself a leaf; handled by the caller. + throw new EmitError(`rule '${rule.name}': unexpected fallback array`); + } + return value.value; +} + +function leafValue(value: Value, rule: StyleRule): EmittedValue { + return value.kind === 'first-that-works' + ? value.values + : staticValue(value, rule); +} + +/** + * Canonical outer→inner nesting order for a condition stack: at-rules + * outermost, then pseudo-classes, then pseudo-elements innermost; alpha + * within a kind. StyleX sums priorities regardless of nesting order, so a + * canonical order is safe and makes the tree merge deterministically. + * Applies the hover-guard: any stack containing `:hover` gains an outer + * `@media (hover: hover)`. + */ +function canonicalPath( + conditions: $ReadOnlyArray, + hoverGuard: boolean, +): Array { + const atRules = conditions + .filter((c) => c.kind === 'at-rule') + .map(conditionKey); + const pseudoClasses = conditions + .filter((c) => c.kind === 'pseudo-class') + .map(conditionKey); + const pseudoElements = conditions + .filter((c) => c.kind === 'pseudo-element') + .map(conditionKey); + if (hoverGuard && pseudoClasses.includes(':hover')) { + atRules.push(HOVER_GUARD); + } + return [...atRules.sort(), ...pseudoClasses.sort(), ...pseudoElements.sort()]; +} + +type MutableTree = { [key: string]: EmittedValue }; + +function insertAtPath( + node: MutableTree, + path: $ReadOnlyArray, + value: EmittedValue, + rule: StyleRule, +): void { + if (path.length === 0) { + if (node.default !== undefined) { + throw new EmitError(`rule '${rule.name}': duplicate base declaration`); + } + node.default = value; + return; + } + const [head, ...rest] = path; + if (rest.length === 0) { + const existing = node[head]; + if ( + existing != null && + typeof existing === 'object' && + !Array.isArray(existing) + ) { + // A deeper path already created an object here; place value at default. + insertAtPath(existing as $FlowFixMe, [], value, rule); + } else if (existing !== undefined) { + throw new EmitError(`rule '${rule.name}': duplicate condition '${head}'`); + } else { + node[head] = value; + } + return; + } + let child = node[head]; + if (child == null || typeof child !== 'object' || Array.isArray(child)) { + const nested: MutableTree = {}; + if (child !== undefined) { + nested.default = child; // promote a previously-plain value + } + node[head] = nested; + child = nested; + } + insertAtPath(child as $FlowFixMe, rest, value, rule); +} + +/** Ensures every condition object has a `default` and its keys are ordered + * (`default` first, then alphabetical) so the output passes stylex/sort-keys + * with zero autofixes. */ +function normalizeTree(value: EmittedValue): EmittedValue { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return value; } - if (value.kind !== 'static' || value.value == null) { - throw new EmitError( - `rule '${rule.name}': only static non-null values are emittable in M1`, + const node = value as EmittedConditions; + const ordered: MutableTree = {}; + ordered.default = + node.default === undefined ? null : normalizeTree(node.default); + for (const key of Object.keys(node) + .filter((k) => k !== 'default') + .sort()) { + ordered[key] = normalizeTree(node[key]); + } + return ordered; +} + +function emitProperty( + atoms: $ReadOnlyArray, + rule: StyleRule, + hoverGuard: boolean, +): EmittedValue { + // Flat fast-path: a single unconditional atom stays a plain value. + if (atoms.length === 1 && atoms[0].conditions.length === 0) { + return leafValue(atoms[0].value, rule); + } + const root: MutableTree = {}; + for (const atom of atoms) { + insertAtPath( + root, + canonicalPath(atom.conditions, hoverGuard), + leafValue(atom.value, rule), + rule, ); } - return value.value; + return normalizeTree(root); } -export function emitFileIR(ir: FileIR): EmitResult { +export function emitFileIR(ir: FileIR, options?: EmitOptions): EmitResult { + const hoverGuard = options?.hoverGuard ?? true; const usedKeys = new Set(); const rules: Array = []; const bindings: Array = []; for (const rule of ir.rules) { - const unsorted: { [string]: EmittedValue } = {}; + // Group atoms by property, preserving source order within each property. + const byProperty: Map> = new Map(); for (const atom of rule.atoms) { - if (atom.conditions.length > 0) { - throw new EmitError( - `rule '${rule.name}': conditions are not emittable until M2`, - ); - } - if (unsorted[atom.property] !== undefined) { - throw new EmitError( - `rule '${rule.name}': duplicate property '${atom.property}'`, - ); - } - unsorted[atom.property] = emitValue(atom.value, rule); + const list = byProperty.get(atom.property) ?? []; + list.push(atom); + byProperty.set(atom.property, list); } // Alphabetical property order satisfies stylex/sort-keys with zero - // autofixes. Safe in M1: flat longhands are order-independent, and the - // order-DEPENDENT cases (duplicates, shorthand/longhand overlap) are - // refused upstream. M4's scoped verifyAndFix supersedes this. + // autofixes. const style: { [string]: EmittedValue } = {}; - for (const property of Object.keys(unsorted).sort()) { - style[property] = unsorted[property]; + for (const property of [...byProperty.keys()].sort()) { + style[property] = emitProperty( + byProperty.get(property) ?? [], + rule, + hoverGuard, + ); } const base = sanitizeKey(rule.name); diff --git a/packages/@stylexjs/codemods/src/core/rewriter.js b/packages/@stylexjs/codemods/src/core/rewriter.js index c02ad8d18..6a1a7abb3 100644 --- a/packages/@stylexjs/codemods/src/core/rewriter.js +++ b/packages/@stylexjs/codemods/src/core/rewriter.js @@ -51,20 +51,42 @@ export function printSource(rewriter: Rewriter): string { } /** - * Renders emitted style data (plain values / fallback arrays) as an - * ObjectExpression — the bridge that lets `core/emit.js` stay AST-free. + * Renders emitted style data (plain values, fallback arrays, or nested + * condition objects) as an ObjectExpression — the bridge that lets + * `core/emit.js` stay AST-free. */ export function styleToObjectAst( j: $FlowFixMe, style: EmittedStyle, ): $FlowFixMe { - const valueAst = (value: EmittedValue): $FlowFixMe => - Array.isArray(value) - ? j.arrayExpression(value.map((v) => j.literal(v))) - : j.literal(value); + return objectAst(j, style); +} + +const IDENTIFIER_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** A property/condition key: bare identifier where legal, else a string + * literal (`':hover'`, `'@media (min-width: 600px)'`). */ +function keyAst(j: $FlowFixMe, key: string): $FlowFixMe { + return IDENTIFIER_KEY.test(key) ? j.identifier(key) : j.literal(key); +} + +function valueAst(j: $FlowFixMe, value: EmittedValue): $FlowFixMe { + if (Array.isArray(value)) { + return j.arrayExpression(value.map((v) => j.literal(v))); + } + if (value != null && typeof value === 'object') { + return objectAst(j, value); + } + return j.literal(value); +} + +function objectAst( + j: $FlowFixMe, + object: { +[string]: EmittedValue }, +): $FlowFixMe { return j.objectExpression( - Object.keys(style).map((property) => - j.property('init', j.identifier(property), valueAst(style[property])), + Object.keys(object).map((key) => + j.property('init', keyAst(j, key), valueAst(j, object[key])), ), ); } From 400fb90f3d769e03d48c3c443b480b225d31ddd6 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 14:15:54 -0400 Subject: [PATCH 12/32] codemods: Emotion adapter parses conditions; transform runs the referee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../codemods/src/adapters/emotion/read.js | 249 ++++++++++++++---- .../src/adapters/emotion/transform.js | 35 ++- 2 files changed, 223 insertions(+), 61 deletions(-) diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/read.js b/packages/@stylexjs/codemods/src/adapters/emotion/read.js index b6200d153..2439361f3 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/read.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/read.js @@ -11,17 +11,21 @@ * L3 — Read. Walks a detected style site's ObjectExpression into neutral * declarations (the first seam hand-off). No StyleX knowledge. * - * M1 scope: flat static string/number values only. Condition keys - * (':hover', '@media …', '&…'), nested objects, spreads, computed keys and - * non-literal values are blockers. A shorthand/longhand overlap inside one - * object (e.g. `margin` + `marginTop`) is refused until the M2 referee can - * arbitrate it — the exact bug class the old attempt shipped. + * M2 scope: flat static values AND self-targeting conditions — pseudo-classes + * (`:hover`), pseudo-elements (`::before`), media queries (`@media`), and + * their nesting. Refuses (whole file, per M1 policy) anything not provably + * self-targeting: descendant/combinator/class selectors, functional pseudos + * (`:not(...)`), conditions nested inside a pseudo-element, and — until the + * M3 normalizer expands shorthands — a shorthand/longhand overlap the M2 + * referee cannot yet arbitrate. */ +import type { Condition, Value } from '../../core/ir'; import type { Declaration } from '../../core/buildIR'; +import { atomCoordinate } from '../../core/ir'; import type { StyleSite } from './detect'; -export type PlainStyleObject = { +[string]: string | number }; +export type PlainStyleObject = { +[string]: mixed }; export type ReadSite = | { @@ -33,6 +37,59 @@ export type ReadSite = | { +ok: false, +blocker: string }; const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const SIMPLE_PSEUDO_CLASS = /^:[a-zA-Z][a-zA-Z-]*$/; +const SIMPLE_PSEUDO_ELEMENT = /^::[a-zA-Z][a-zA-Z-]*$/; +const AT_RULE = /^@(media|supports|container)\b/; + +type Classified = + | { +role: 'condition', +condition: Condition, +normalizedKey: string } + | { +role: 'refuse', +reason: string }; + +/** Classifies an object-valued key as a self-targeting condition or a + * refusal. Strips a leading `&` (Emotion self-reference). */ +function classifyConditionKey(rawKey: string): Classified { + let key = rawKey.trim(); + if (key.startsWith('&')) { + key = key.slice(1).trim(); + } + if (key === '') { + return { role: 'refuse', reason: 'bare `&` self-reference' }; + } + if (AT_RULE.test(key)) { + return { + role: 'condition', + condition: { kind: 'at-rule', rule: key }, + normalizedKey: key, + }; + } + if (key.startsWith('@')) { + return { role: 'refuse', reason: `unsupported at-rule '${rawKey}'` }; + } + if (SIMPLE_PSEUDO_ELEMENT.test(key)) { + return { + role: 'condition', + condition: { kind: 'pseudo-element', name: key }, + normalizedKey: key, + }; + } + if (SIMPLE_PSEUDO_CLASS.test(key)) { + return { + role: 'condition', + condition: { kind: 'pseudo-class', name: key }, + normalizedKey: key, + }; + } + if (key.startsWith(':')) { + return { + role: 'refuse', + reason: `functional or complex pseudo-selector '${rawKey}'`, + }; + } + return { + role: 'refuse', + reason: `selector '${rawKey}' is not self-targeting`, + }; +} function literalValue(node: $FlowFixMe): string | number | null { if ( @@ -52,27 +109,46 @@ function literalValue(node: $FlowFixMe): string | number | null { return null; } +/** A property's value: a static literal, or a fallback array of them. */ +function valueOf(node: $FlowFixMe): Value | null { + const literal = literalValue(node); + if (literal != null) { + return { kind: 'static', value: literal }; + } + if (node.type === 'ArrayExpression' && node.elements.length > 0) { + const values: Array = []; + for (const element of node.elements) { + const v = literalValue(element); + if (v == null) { + return null; + } + values.push(v); + } + return { kind: 'first-that-works', values }; + } + return null; +} + function propertyKey(node: $FlowFixMe): string | null { if (node.type === 'Identifier') { return node.name; } if ( (node.type === 'Literal' || node.type === 'StringLiteral') && - typeof node.value === 'string' && - IDENTIFIER.test(node.value) + typeof node.value === 'string' ) { return node.value; } return null; } -/** - * Conservative shorthand/longhand overlap check: `marginTop` overlaps - * `margin` because it extends it with a capitalized segment. Errs toward - * false positives (`border` vs `borderRadius`) — a false positive skips a - * file (safe); the M2 referee replaces this with real priority data from - * `@stylexjs/shared`. - */ +function plainOf(value: Value): mixed { + return value.kind === 'first-that-works' ? [...value.values] : value.value; +} + +/** Conservative shorthand/longhand overlap within one condition group: + * `marginTop` extends `margin`. False positives only skip a file (safe); + * the M2 referee + M3 normalizer replace this with real priority data. */ function findShorthandOverlap(properties: Array): string | null { for (const a of properties) { for (const b of properties) { @@ -86,54 +162,119 @@ function findShorthandOverlap(properties: Array): string | null { export function readSite(site: StyleSite): ReadSite { const declarations: Array = []; - const cssObject: { [string]: string | number } = {}; + const cssObject: { [string]: mixed } = {}; let label: string | null = null; - for (const property of site.objectNode.properties) { - if (property.type !== 'Property' && property.type !== 'ObjectProperty') { - return { ok: false, blocker: 'spread in style object' }; - } - if (property.computed) { - return { ok: false, blocker: 'computed key in style object' }; + const walk = ( + objectNode: $FlowFixMe, + conditions: $ReadOnlyArray, + insidePseudoElement: boolean, + mirror: { [string]: mixed }, + ): string | null => { + for (const property of objectNode.properties) { + if (property.type !== 'Property' && property.type !== 'ObjectProperty') { + return 'spread in style object'; + } + if (property.computed) { + return 'computed key in style object'; + } + const key = propertyKey(property.key); + if (key == null) { + return 'un-analyzable style key'; + } + const valueNode = property.value; + + if (valueNode.type === 'ObjectExpression') { + const classified = classifyConditionKey(key); + if (classified.role === 'refuse') { + return classified.reason; + } + if (insidePseudoElement) { + return `condition '${key}' nested inside a pseudo-element`; + } + const nested: { [string]: mixed } = {}; + mirror[classified.normalizedKey] = nested; + // insidePseudoElement is always false here (we returned above if it + // was true), so the new flag is just whether THIS key is one. + const blocker = walk( + valueNode, + [...conditions, classified.condition], + classified.condition.kind === 'pseudo-element', + nested, + ); + if (blocker != null) { + return blocker; + } + continue; + } + + if (key === 'label' && conditions.length === 0) { + const literal = literalValue(valueNode); + if (typeof literal === 'string') { + label = literal; + continue; // debugging metadata, not a CSS declaration + } + } + + if (!IDENTIFIER.test(key) && !/^[a-zA-Z-]+$/.test(key)) { + return `un-convertible style key '${key}'`; + } + const value = valueOf(valueNode); + if (value == null) { + return ( + `value of '${key}' is not a static string/number/fallback-array ` + + '(dynamic values land in v1.1)' + ); + } + declarations.push({ property: key, value, conditions }); + mirror[key] = plainOf(value); } - const key = propertyKey(property.key); - if (key == null) { + return null; + }; + + const blocker = walk(site.objectNode, [], false, cssObject); + if (blocker != null) { + return { ok: false, blocker }; + } + + if (declarations.length === 0) { + return { ok: false, blocker: 'empty style object' }; + } + + // Duplicate-coordinate and shorthand-overlap checks, per condition group. + const byCoordinate: Map> = new Map(); + for (const declaration of declarations) { + const coord = atomCoordinate( + declaration.property, + declaration.conditions ?? [], + ); + const group = byCoordinate.get(coord) ?? []; + group.push(declaration.property); + byCoordinate.set(coord, group); + } + const seenProps: Map> = new Map(); + for (const declaration of declarations) { + const conditionKey = (declaration.conditions ?? []) + .map((c) => atomCoordinate('', [c])) + .join('&&'); + const set = seenProps.get(conditionKey) ?? new Set(); + if (set.has(declaration.property)) { return { ok: false, - blocker: - `style key ${describeKey(property.key)} is not convertible yet ` + - '(conditions land in M2, kebab-case keys in M3)', + blocker: `duplicate style key '${declaration.property}'`, }; } - const value = literalValue(property.value); - if (value == null) { + set.add(declaration.property); + seenProps.set(conditionKey, set); + } + for (const [, group] of seenProps) { + const overlap = findShorthandOverlap([...group]); + if (overlap != null) { return { ok: false, - blocker: - `value of '${key}' is not a static string/number ` + - '(nested conditions land in M2; dynamic values in v1.1)', + blocker: `shorthand/longhand overlap (${overlap}) needs the M2 referee`, }; } - if (key === 'label' && typeof value === 'string') { - label = value; - continue; // debugging metadata, not a CSS declaration - } - if (cssObject[key] !== undefined) { - return { ok: false, blocker: `duplicate style key '${key}'` }; - } - declarations.push({ property: key, value }); - cssObject[key] = value; - } - - if (declarations.length === 0) { - return { ok: false, blocker: 'empty style object' }; - } - const overlap = findShorthandOverlap(declarations.map((d) => d.property)); - if (overlap != null) { - return { - ok: false, - blocker: `shorthand/longhand overlap (${overlap}) needs the M2 referee`, - }; } return { @@ -144,10 +285,6 @@ export function readSite(site: StyleSite): ReadSite { }; } -function describeKey(node: $FlowFixMe): string { - return typeof node.value === 'string' ? `'${node.value}'` : `<${node.type}>`; -} - function enclosingComponentName(site: StyleSite): string | null { for (let path = site.attrPath; path != null; path = path.parent) { const node = path.node; diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js index e92d278f3..7927c10e8 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -19,6 +19,8 @@ import { buildFileIR } from '../../core/buildIR'; import { emitFileIR } from '../../core/emit'; +import type { EmittedRule } from '../../core/emit'; +import { checkRule } from '../../core/referee'; import { parseSource, printSource, @@ -45,9 +47,15 @@ export type TransformResult = | { +status: 'skipped', +reasons: $ReadOnlyArray } | { +status: 'unchanged' }; +export type TransformOptions = { + /** Wrap `:hover` in `@media (hover: hover)` (default true). */ + +hoverGuard?: boolean, +}; + export function transformEmotionFile( source: string, filename: string = 'file.js', + options?: TransformOptions, ): TransformResult { // Cheap bail before parsing anything. if (!source.includes('@emotion/react')) { @@ -85,7 +93,27 @@ export function transformEmotionFile( } return { nameHint: read.nameHint, declarations: read.declarations }; }); - const { rules, bindings } = emitFileIR(buildFileIR(groups)); + const fileIR = buildFileIR(groups); + + // L5 Referee: convert only when Emotion's cascade and StyleX's priority + // agree on every simultaneously-active condition; otherwise refuse. + const refereed = fileIR.rules.map(checkRule); + const conflicts = refereed.flatMap((r) => (r.ok ? [] : r.conflicts)); + if (conflicts.length > 0) { + return { status: 'skipped', reasons: conflicts }; + } + const { rules, bindings } = emitFileIR( + { + rules: refereed.map((r) => { + if (!r.ok) { + throw new Error('unreachable: conflicts were checked above'); + } + return r.rule; + }), + keyframes: fileIR.keyframes, + }, + { hoverGuard: options?.hoverGuard ?? true }, + ); // Core -> adapter: place the StyleX back into the file's idiom. const stylesLocalName = pickStylesName(j, root); @@ -130,10 +158,7 @@ function insertRegistry( root: $FlowFixMe, firstSite: $FlowFixMe, stylesLocalName: string, - rules: $ReadOnlyArray<{ - +key: string, - +style: { +[string]: string | number | $ReadOnlyArray }, - }>, + rules: $ReadOnlyArray, ): void { const createObject = j.objectExpression( rules.map((rule) => From cb926224fedbf1b62883a6a2748ca7ed7f272226 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 14:15:58 -0400 Subject: [PATCH 13/32] =?UTF-8?q?codemods:=20M2=20fixtures=20=E2=80=94=20h?= =?UTF-8?q?over/focus/media/pseudo-element=20convert;=20referee=20&=20desc?= =?UTF-8?q?endant=20refuse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../emotion/focus-media/expected.js | 21 +++++++++++++++++++ .../__fixtures__/emotion/focus-media/input.js | 14 +++++++++++++ .../emotion/hover-pseudo/expected.js | 18 ++++++++++++++-- .../emotion/pseudo-element/expected.js | 16 ++++++++++++++ .../emotion/pseudo-element/input.js | 15 +++++++++++++ .../skip-descendant-selector/expected.js | 12 +++++++++++ .../emotion/skip-descendant-selector/input.js | 12 +++++++++++ .../emotion/skip-referee-conflict/expected.js | 18 ++++++++++++++++ .../emotion/skip-referee-conflict/input.js | 18 ++++++++++++++++ 9 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/expected.js new file mode 100644 index 000000000..b075743a5 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/expected.js @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + field: { + color: { + default: 'black', + ':focus': 'blue', + }, + + fontSize: { + default: null, + '@media (min-width: 600px)': 18, + }, + }, +}); + +export default function Field() { + return ; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/input.js new file mode 100644 index 000000000..9fb728f51 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/focus-media/input.js @@ -0,0 +1,14 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Field() { + return ( + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js index c63e63fa1..60ac4ec14 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/hover-pseudo/expected.js @@ -1,9 +1,23 @@ -/** @jsxImportSource @emotion/react */ import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + link: { + color: { + default: 'blue', + + '@media (hover: hover)': { + default: null, + ':hover': 'navy', + }, + }, + }, +}); + export default function Link() { return ( - + Link ); diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/expected.js new file mode 100644 index 000000000..21bfe485a --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + tag: { + color: { + default: 'black', + '::before': 'gray', + }, + }, +}); + +export default function Tag() { + return Tag; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/input.js new file mode 100644 index 000000000..c91ff2813 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/pseudo-element/input.js @@ -0,0 +1,15 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Tag() { + return ( + + Tag + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.js new file mode 100644 index 000000000..79bcb3666 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.js @@ -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 ( +
    li': { color: 'gray' } }}> +
  • Item
  • +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/input.js new file mode 100644 index 000000000..79bcb3666 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/input.js @@ -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 ( +
    li': { color: 'gray' } }}> +
  • Item
  • +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.js new file mode 100644 index 000000000..c38b05faa --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.js @@ -0,0 +1,18 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// :focus before :hover — Emotion's source order makes :hover win when both +// are active, but StyleX's priority makes :focus win. The referees disagree, +// so the whole file must be refused (not silently converted incorrectly). +export default function Toggle() { + return ( + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/input.js new file mode 100644 index 000000000..c38b05faa --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/input.js @@ -0,0 +1,18 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// :focus before :hover — Emotion's source order makes :hover win when both +// are active, but StyleX's priority makes :focus win. The referees disagree, +// so the whole file must be refused (not silently converted incorrectly). +export default function Toggle() { + return ( + + ); +} From 74bf5500880e7c35dca9949a87bf1ab77e4a8e0d Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Mon, 20 Jul 2026 14:16:38 -0400 Subject: [PATCH 14/32] =?UTF-8?q?codemods:=20IR-completeness=20reader=20ha?= =?UTF-8?q?ndles=20conditions=20=E2=80=94=20coverage=202/5=20->=205/5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/ir-completeness-test.js | 37 +++++-- .../codemods/src/testing/stylexToIR.js | 99 +++++++++++++------ 2 files changed, 97 insertions(+), 39 deletions(-) diff --git a/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js index e68fa8adb..65c51de62 100644 --- a/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js +++ b/packages/@stylexjs/codemods/__tests__/ir-completeness-test.js @@ -58,20 +58,34 @@ function valueToSource(value: EmittedValue | mixed): string { if (typeof value === 'number') { return String(value); } + if (value === null) { + return 'null'; + } if (Array.isArray(value)) { return `[${value.map(valueToSource).join(', ')}]`; } + if (typeof value === 'object') { + // A nested condition object: { default, ':hover', '@media …' }. + return objectToSource(value); + } throw new Error(`unstringifiable value: ${String(value)}`); } +const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +function objectToSource(object: { +[string]: mixed }): string { + const entries = Object.keys(object).map((key) => { + const renderedKey = BARE_KEY.test(key) ? key : `'${key}'`; + return `${renderedKey}: ${valueToSource(object[key])}`; + }); + return `{ ${entries.join(', ')} }`; +} + function styleToSource(style: EmittedStyle | mixed): string { if (style == null || typeof style !== 'object') { throw new Error('unstringifiable style object'); } - const entries = Object.keys(style).map( - (property) => `${property}: ${valueToSource(style[property])}`, - ); - return `{ ${entries.join(', ')} }`; + return objectToSource(style); } function netCssOfStyleObject(style: EmittedStyle | mixed) { @@ -104,8 +118,13 @@ test('every corpus entry round-trips through the IR or is a typed coverage gap', continue; } // Round-trip: IR -> emit -> compile, and compare against the original - // object compiled directly. Empty allowlist: any drift is a failure. - const { rules } = emitFileIR({ rules: [rule], keyframes: [] }); + // object compiled directly. hoverGuard is off so the round-trip is a + // true identity (the guard is a sanctioned change, not an equivalence); + // empty allowlist means any drift is a failure. + const { rules } = emitFileIR( + { rules: [rule], keyframes: [] }, + { hoverGuard: false }, + ); const diff = semanticDiffGate( netCssOfStyleObject(styleObject), netCssOfStyleObject(rules[0].style), @@ -119,10 +138,8 @@ test('every corpus entry round-trips through the IR or is a typed coverage gap', covered.push(name); } expect(covered.length + gaps.length).toBe(SEED_CORPUS.length); - expect(covered).toEqual([ - 'flat static styles', - 'fallback array (firstThatWorks)', - ]); + // M2 covers conditions, so the whole seed corpus now round-trips. + expect(gaps).toEqual([]); // eslint-disable-next-line no-console console.info( `[ir-completeness] ${covered.length}/${SEED_CORPUS.length} corpus entries covered` + diff --git a/packages/@stylexjs/codemods/src/testing/stylexToIR.js b/packages/@stylexjs/codemods/src/testing/stylexToIR.js index 5c9e4a717..004765676 100644 --- a/packages/@stylexjs/codemods/src/testing/stylexToIR.js +++ b/packages/@stylexjs/codemods/src/testing/stylexToIR.js @@ -16,12 +16,13 @@ * coverage percentage. An IR gap found here is SAFE — in the real pipeline * the same construct would be flagged, never emitted incorrectly. * - * M1 coverage: flat static values and fallback arrays. Condition objects - * (pseudo/at-rule keys or condition-in-value objects) are M2 gaps; null - * values and keyframes are M3 gaps. + * M2 coverage: flat static values, fallback arrays, AND condition-in-value + * objects (`{ default, ':hover', '@media …', '::before' }`, including + * nesting) — the StyleX-side proof that the flip round-trips. `null` values + * and keyframes are M3 gaps. */ -import type { Atom, StyleRule } from '../core/ir'; +import type { Atom, Condition, StyleRule, Value } from '../core/ir'; export class IRCoverageGapError extends Error { constructor(message: string) { @@ -47,6 +48,70 @@ function toStaticValueArray( return values; } +/** Parses a StyleX condition key into a typed IR condition, or throws. */ +function toCondition(context: string, key: string): Condition { + if (key.startsWith('@')) { + return { kind: 'at-rule', rule: key }; + } + if (key.startsWith('::')) { + return { kind: 'pseudo-element', name: key }; + } + if (key.startsWith(':')) { + return { kind: 'pseudo-class', name: key }; + } + throw new IRCoverageGapError( + `${context}: unrecognized condition key '${key}'`, + ); +} + +/** A static leaf value, or null if not one. */ +function toLeafValue(raw: mixed): Value | null { + if (typeof raw === 'string' || typeof raw === 'number') { + return { kind: 'static', value: raw }; + } + if (Array.isArray(raw) && raw.length > 0) { + const values = toStaticValueArray(raw); + return values == null ? null : { kind: 'first-that-works', values }; + } + return null; +} + +/** Walks a property's value — a leaf, or a condition object (possibly + * nested) — emitting one atom per leaf with its accumulated conditions. */ +function readProperty( + context: string, + property: string, + raw: mixed, + conditions: $ReadOnlyArray, + atoms: Array, +): void { + const leaf = toLeafValue(raw); + if (leaf != null) { + atoms.push({ property, conditions, value: leaf }); + return; + } + if (raw == null || typeof raw !== 'object') { + throw new IRCoverageGapError( + `${context}.${property}: value form not representable yet ` + + '(null/keyframes land in M3)', + ); + } + const conditionObject: { +[string]: mixed } = raw; + for (const key of Object.keys(conditionObject)) { + if (key === 'default') { + readProperty(context, property, conditionObject[key], conditions, atoms); + } else { + readProperty( + context, + property, + conditionObject[key], + [...conditions, toCondition(`${context}.${property}`, key)], + atoms, + ); + } + } +} + export function stylexObjectToIR(name: string, styleObject: mixed): StyleRule { if ( styleObject == null || @@ -57,31 +122,7 @@ export function stylexObjectToIR(name: string, styleObject: mixed): StyleRule { } const atoms: Array = []; for (const property of Object.keys(styleObject)) { - const raw = styleObject[property]; - if (typeof raw === 'string' || typeof raw === 'number') { - atoms.push({ - property, - conditions: [], - value: { kind: 'static', value: raw }, - }); - } else if (Array.isArray(raw) && raw.length > 0) { - const values = toStaticValueArray(raw); - if (values == null) { - throw new IRCoverageGapError( - `'${name}.${property}': fallback array contains a non-static entry`, - ); - } - atoms.push({ - property, - conditions: [], - value: { kind: 'first-that-works', values }, - }); - } else { - throw new IRCoverageGapError( - `'${name}.${property}': value form not representable yet ` + - '(conditions land in M2, null/keyframes in M3)', - ); - } + readProperty(name, property, styleObject[property], [], atoms); } return { name, atoms }; } From 44ee2db43420e53ea7e9d9119d44234dbff367ed Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 01:13:41 -0400 Subject: [PATCH 15/32] =?UTF-8?q?codemods:=20normalize=20(L6)=20=E2=80=94?= =?UTF-8?q?=20physical=20to=20logical=20property=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../codemods/__tests__/normalize-test.js | 100 ++++++++++++++++++ .../@stylexjs/codemods/src/core/normalize.js | 64 +++++++++++ 2 files changed, 164 insertions(+) create mode 100644 packages/@stylexjs/codemods/__tests__/normalize-test.js create mode 100644 packages/@stylexjs/codemods/src/core/normalize.js diff --git a/packages/@stylexjs/codemods/__tests__/normalize-test.js b/packages/@stylexjs/codemods/__tests__/normalize-test.js new file mode 100644 index 000000000..7337d19c9 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/normalize-test.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Normalize unit tests — hand-written IR, no Emotion. + */ + +import type { FileIR } from '../src/core/ir'; +import { normalizeFileIR } from '../src/core/normalize'; + +function irOf(props: Array<[string, string | number]>): FileIR { + return { + rules: [ + { + name: 'x', + atoms: props.map(([property, value]) => ({ + property, + conditions: [], + value: { kind: 'static', value }, + })), + }, + ], + keyframes: [], + }; +} + +function properties(ir: FileIR): Array { + return ir.rules[0].atoms.map((a) => a.property); +} + +describe('normalizeFileIR — physical to logical', () => { + test('maps inline-axis physical properties to logical', () => { + const out = normalizeFileIR( + irOf([ + ['marginLeft', 8], + ['marginRight', 8], + ['paddingLeft', 4], + ['paddingRight', 4], + ['left', 0], + ['right', 0], + ]), + ); + expect(properties(out)).toEqual([ + 'marginInlineStart', + 'marginInlineEnd', + 'paddingInlineStart', + 'paddingInlineEnd', + 'insetInlineStart', + 'insetInlineEnd', + ]); + }); + + test('leaves block-axis and non-directional properties untouched', () => { + const out = normalizeFileIR( + irOf([ + ['marginTop', 8], + ['bottom', 0], + ['color', 'red'], + ]), + ); + expect(properties(out)).toEqual(['marginTop', 'bottom', 'color']); + }); + + test('preserves conditions and values while renaming', () => { + const ir: FileIR = { + rules: [ + { + name: 'x', + atoms: [ + { + property: 'marginLeft', + conditions: [{ kind: 'pseudo-class', name: ':hover' }], + value: { kind: 'static', value: 12 }, + }, + ], + }, + ], + keyframes: [], + }; + const out = normalizeFileIR(ir); + expect(out.rules[0].atoms[0]).toEqual({ + property: 'marginInlineStart', + conditions: [{ kind: 'pseudo-class', name: ':hover' }], + value: { kind: 'static', value: 12 }, + }); + }); + + test('can be disabled', () => { + const out = normalizeFileIR(irOf([['marginLeft', 8]]), { + logicalProperties: false, + }); + expect(properties(out)).toEqual(['marginLeft']); + }); +}); diff --git a/packages/@stylexjs/codemods/src/core/normalize.js b/packages/@stylexjs/codemods/src/core/normalize.js new file mode 100644 index 000000000..9aa06f536 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/normalize.js @@ -0,0 +1,64 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L6 — Normalize. Value & vocabulary normalization on the IR. + * + * M3a: physical → logical property mapping (`marginLeft` → + * `marginInlineStart`, `left` → `insetInlineStart`, …). This is a + * DELIBERATE, doc-sanctioned RTL behavior change — in a right-to-left + * document the logical property flips side where the physical one would + * not. It is covered by the semantic-diff gate's physical→logical allowlist + * (which proves LTR-equivalence), and it matches StyleX's own preference + * for logical properties. On by default; disable with + * `{ logicalProperties: false }` (full config lands in M6). + * + * Runs BEFORE the referee so all downstream layers see one consistent + * vocabulary. Only the inline-axis pairs are mapped — block-axis (`top`, + * `bottom`) does not flip in RTL and is left physical. + * + * Later M3 slices add here: multi-value shorthand expansion + * (`style-value-parser`) and unitless handling. + */ + +import type { Atom, FileIR, StyleRule } from './ir'; + +export type NormalizeOptions = { + /** Map inline-axis physical properties to logical ones (default true). */ + +logicalProperties?: boolean, +}; + +/** Inline-axis physical → logical property names (camelCase, matching the + * semantic-diff gate's allowlist). Block-axis stays physical (no RTL flip). */ +const PHYSICAL_TO_LOGICAL: $ReadOnly<{ [string]: string }> = { + marginLeft: 'marginInlineStart', + marginRight: 'marginInlineEnd', + paddingLeft: 'paddingInlineStart', + paddingRight: 'paddingInlineEnd', + left: 'insetInlineStart', + right: 'insetInlineEnd', +}; + +export function normalizeFileIR( + ir: FileIR, + options?: NormalizeOptions, +): FileIR { + const logical = options?.logicalProperties ?? true; + if (!logical) { + return ir; + } + const rules: $ReadOnlyArray = ir.rules.map((rule) => ({ + name: rule.name, + atoms: rule.atoms.map((atom: Atom) => { + const mapped = PHYSICAL_TO_LOGICAL[atom.property]; + return mapped == null ? atom : { ...atom, property: mapped }; + }), + })); + return { rules, keyframes: ir.keyframes }; +} From 359f29af5165b8c7942b1e29c4d5aa33347cf349 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 01:13:45 -0400 Subject: [PATCH 16/32] =?UTF-8?q?codemods:=20postprocess=20(L10)=20?= =?UTF-8?q?=E2=80=94=20run=20StyleX's=20own=20eslint=20autofixes=20on=20th?= =?UTF-8?q?e=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../codemods/src/core/postprocess.js | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/@stylexjs/codemods/src/core/postprocess.js diff --git a/packages/@stylexjs/codemods/src/core/postprocess.js b/packages/@stylexjs/codemods/src/core/postprocess.js new file mode 100644 index 000000000..7462f2dcb --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/postprocess.js @@ -0,0 +1,70 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * L10 — Postprocess. Runs StyleX's OWN eslint autofixes over the emitted + * output, so the result passes `@stylexjs/eslint-plugin` at error with zero + * autofixes remaining (Meta's golden rule) — and, crucially, so key + * ordering and shorthand handling exactly match StyleX's canonical form + * rather than a heuristic we maintain. + * + * M3: run file-wide. This is safe *now* because a converted file has no + * pre-existing user `stylex.create` (files that do are refused upstream), + * so the only nodes the autofix can touch are the ones we emitted. M4 adds + * the scoping needed once we merge into a user's existing registry. + * + * The sort-keys autofix can reorder sibling media queries, which the StyleX + * compiler treats as semantic — but the semantic-diff gate runs on this + * function's OUTPUT, so any reorder that changes rendering is caught (the + * file is refused) rather than shipped. + * + * Residual (unfixable) errors mean the output is not clean; the caller + * refuses the whole file. + */ + +import { Linter } from 'eslint'; +import * as hermesEslint from 'hermes-eslint'; +import { rules as stylexRules } from '@stylexjs/eslint-plugin'; + +export type PostprocessResult = { + +code: string, + +residualErrors: $ReadOnlyArray, +}; + +const PARSER_NAME = 'hermes-eslint'; + +export function postprocess( + code: string, + filename: string = 'file.js', +): PostprocessResult { + const linter = new Linter(); + linter.defineParser(PARSER_NAME, hermesEslint); + const ruleMap: { +[string]: mixed } = stylexRules; + const config: { [string]: 'error' } = {}; + for (const ruleName of Object.keys(ruleMap)) { + const qualified = `@stylexjs/${ruleName}`; + linter.defineRule(qualified, ruleMap[ruleName]); + config[qualified] = 'error'; + } + + const verifyConfig = { + parser: PARSER_NAME, + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + rules: config, + }; + const fixed = linter.verifyAndFix(code, verifyConfig, { filename }); + const residual = linter.verify(fixed.output, verifyConfig, { filename }); + + return { + code: fixed.output, + residualErrors: residual.map( + (m) => `${m.ruleId ?? 'error'}: ${m.message} (line ${m.line ?? 0})`, + ), + }; +} From 59ad8ebc70b36b0384a5d9deff3a6dab947f8fce Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 01:15:47 -0400 Subject: [PATCH 17/32] codemods: referee guards the sibling-media reorder hazard 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 --- .../codemods/__tests__/referee-test.js | 20 ++++++ .../@stylexjs/codemods/src/core/referee.js | 63 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/@stylexjs/codemods/__tests__/referee-test.js b/packages/@stylexjs/codemods/__tests__/referee-test.js index 05be7a7e1..3e21518aa 100644 --- a/packages/@stylexjs/codemods/__tests__/referee-test.js +++ b/packages/@stylexjs/codemods/__tests__/referee-test.js @@ -92,6 +92,26 @@ describe('checkRule — disagreement (refuses)', () => { const result = checkRule(rule([media(MEDIA, 'green'), base('red')])); expect(result.ok).toBe(false); }); + + test('≥2 sibling media queries on one property (sort-keys reorder hazard)', () => { + const result = checkRule( + rule([ + base('black'), + media('@media (min-width: 500px)', 'blue'), + media('@media (min-width: 700px)', 'green'), + ]), + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.conflicts[0]).toMatch(/sibling at-rule/); + } + }); + + test('a single media query alongside base is fine', () => { + expect(checkRule(rule([base('black'), media(MEDIA, 'blue')])).ok).toBe( + true, + ); + }); }); describe('checkRule — dedup', () => { diff --git a/packages/@stylexjs/codemods/src/core/referee.js b/packages/@stylexjs/codemods/src/core/referee.js index 0d59da427..e9fc2bcaf 100644 --- a/packages/@stylexjs/codemods/src/core/referee.js +++ b/packages/@stylexjs/codemods/src/core/referee.js @@ -69,6 +69,61 @@ function coordinate(atom: Atom): string { return atom.conditions.map(conditionKey).slice().sort().join('&&'); } +/** Signature of an atom's non-at-rule context (pseudo-classes + elements) — + * two atoms with the same signature but different at-rules become sibling + * media keys under one object. */ +function nonAtRuleSignature(conditions: $ReadOnlyArray): string { + return conditions + .filter((c) => c.kind !== 'at-rule') + .map(conditionKey) + .sort() + .join('&&'); +} + +function atRuleSignature(conditions: $ReadOnlyArray): string { + return conditions + .filter((c) => c.kind === 'at-rule') + .map(conditionKey) + .sort() + .join('&&'); +} + +/** + * The sibling-at-rule hazard: when one property has ≥2 atoms that share a + * pseudo context but differ by at-rule, they become sibling media keys in + * the emitted object. StyleX's sort-keys autofix reorders sibling keys, but + * the compiler treats sibling media order as SEMANTIC (last matching wins), + * so a reorder can silently change rendering — and the per-coordinate + * semantic-diff gate cannot see it. Until that upstream inconsistency is + * resolved we refuse (never emit incorrectly). Returns a conflict string or null. + */ +function siblingAtRuleConflict( + property: string, + atoms: $ReadOnlyArray, +): string | null { + const atRulesByContext: Map> = new Map(); + for (const atom of atoms) { + const at = atRuleSignature(atom.conditions); + if (at === '') { + continue; + } + const context = nonAtRuleSignature(atom.conditions); + const set = atRulesByContext.get(context) ?? new Set(); + set.add(at); + atRulesByContext.set(context, set); + } + for (const [, set] of atRulesByContext) { + if (set.size >= 2) { + return ( + `'${property}': ≥2 sibling at-rule (e.g. @media) conditions whose order ` + + 'is semantic to the StyleX compiler but which sort-keys would reorder ' + + `(${[...set].join(', ')})` + ); + } + } + return null; +} + /** * Checks one rule. On success returns the rule with same-coordinate * duplicates collapsed (last-in-source wins, matching both Emotion and @@ -98,6 +153,14 @@ export function checkRule(rule: StyleRule): RefereeResult { } const deduped = [...lastByCoord.values()]; + const siblingConflict = siblingAtRuleConflict( + property, + deduped.map((e) => e.atom), + ); + if (siblingConflict != null) { + conflicts.push(siblingConflict); + } + // Partition by pseudo-element target; each box is refereed independently. const byTarget: Map< string, From a6bed74c5c3fed16eb67b5994efe2278cf53ddf7 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 01:15:51 -0400 Subject: [PATCH 18/32] codemods: wire normalize (before referee) and postprocess (after print) into transform Threads logicalProperties + hoverGuard options; refuses on postprocess residual errors. Co-Authored-By: Claude Opus 4.8 --- .../src/adapters/emotion/transform.js | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js index 7927c10e8..f27c682ce 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -20,6 +20,8 @@ import { buildFileIR } from '../../core/buildIR'; import { emitFileIR } from '../../core/emit'; import type { EmittedRule } from '../../core/emit'; +import { normalizeFileIR } from '../../core/normalize'; +import { postprocess } from '../../core/postprocess'; import { checkRule } from '../../core/referee'; import { parseSource, @@ -50,6 +52,8 @@ export type TransformResult = export type TransformOptions = { /** Wrap `:hover` in `@media (hover: hover)` (default true). */ +hoverGuard?: boolean, + /** Map inline-axis physical properties to logical (default true). */ + +logicalProperties?: boolean, }; export function transformEmotionFile( @@ -93,7 +97,11 @@ export function transformEmotionFile( } return { nameHint: read.nameHint, declarations: read.declarations }; }); - const fileIR = buildFileIR(groups); + // L6 Normalize runs before the referee so every downstream layer sees one + // vocabulary (physical→logical is a sanctioned RTL change). + const fileIR = normalizeFileIR(buildFileIR(groups), { + logicalProperties: options?.logicalProperties ?? true, + }); // L5 Referee: convert only when Emotion's cascade and StyleX's priority // agree on every simultaneously-active condition; otherwise refuse. @@ -125,9 +133,20 @@ export function transformEmotionFile( removeCssImport(j, root); removePragma(j, root); + // L10 Postprocess: run StyleX's own eslint autofixes so key ordering and + // shorthands match its canonical form. Unfixable residual errors mean the + // output is not clean at error → refuse the whole file. + const { code, residualErrors } = postprocess( + printSource({ j, root }), + filename, + ); + if (residualErrors.length > 0) { + return { status: 'skipped', reasons: residualErrors }; + } + return { status: 'converted', - code: printSource({ j, root }), + code, sites: detection.sites.map((site, i) => { const read = reads[i]; if (!read.ok) { From cef65c2c5697ed7c3bea2b4a9bf4f843e35e91ae Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 01:15:55 -0400 Subject: [PATCH 19/32] =?UTF-8?q?codemods:=20M3a=20fixtures=20=E2=80=94=20?= =?UTF-8?q?logical-properties=20converts;=20skip-sibling-media=20refuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../emotion/logical-properties/expected.js | 16 ++++++++++++++++ .../emotion/logical-properties/input.js | 10 ++++++++++ .../emotion/skip-sibling-media/expected.js | 19 +++++++++++++++++++ .../emotion/skip-sibling-media/input.js | 19 +++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/expected.js new file mode 100644 index 000000000..365be0b0b --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + sidebar: { + insetInlineStart: 0, + marginInlineStart: 16, + paddingInlineEnd: 8, + marginTop: 4, + }, +}); + +export default function Sidebar() { + return ; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/input.js new file mode 100644 index 000000000..c9d3ddcc4 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/logical-properties/input.js @@ -0,0 +1,10 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Sidebar() { + return ( + + ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.js new file mode 100644 index 000000000..b53202b41 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.js @@ -0,0 +1,19 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// Two sibling media queries on one property: their source order is semantic +// to the StyleX compiler, but sort-keys would reorder them. Refused until the +// upstream inconsistency is resolved. +export default function Panel() { + return ( +
+ Panel +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/input.js new file mode 100644 index 000000000..b53202b41 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/input.js @@ -0,0 +1,19 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +// Two sibling media queries on one property: their source order is semantic +// to the StyleX compiler, but sort-keys would reorder them. Refused until the +// upstream inconsistency is resolved. +export default function Panel() { + return ( +
+ Panel +
+ ); +} From aadb19e8d8dab20f3378e2da45474ef71134e6f9 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 14:22:13 -0400 Subject: [PATCH 20/32] codemods: semantic-diff gate canonicalizes box shorthands to physical 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 --- .../codemods/__tests__/gates-test.js | 25 +++- .../codemods/src/core/gates/semanticDiff.js | 132 +++++++++++++++++- 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/packages/@stylexjs/codemods/__tests__/gates-test.js b/packages/@stylexjs/codemods/__tests__/gates-test.js index dc0efe1f4..0bf01c385 100644 --- a/packages/@stylexjs/codemods/__tests__/gates-test.js +++ b/packages/@stylexjs/codemods/__tests__/gates-test.js @@ -140,15 +140,34 @@ describe('semantic-diff gate', () => { expect(result.allowed.length).toBeGreaterThan(0); }); - test('allowlist: physical -> logical is a sanctioned diff', () => { + test('allowlist: physical -> logical is a sanctioned diff (inset, not canonicalized)', () => { + // margin/padding are canonicalized to physical longhands and match + // directly; left/right (inset) still flow through the allowlist. const result = semanticDiffGate( - emotionNetCss({ marginLeft: '8px' }), - emotionNetCss({ marginInlineStart: '8px' }), + emotionNetCss({ left: '0px' }), + emotionNetCss({ insetInlineStart: '0px' }), ); expect(result.ok).toBe(true); expect(result.allowed.length).toBeGreaterThan(0); }); + test('box shorthand and its expanded longhands compare equal', () => { + // Emotion `margin: 8px 16px` vs the codemod's logical expansion. + const result = semanticDiffGate( + emotionNetCss({ margin: '8px 16px' }), + emotionNetCss({ marginBlock: '8px', marginInline: '16px' }), + ); + expect(result.ok).toBe(true); + }); + + test('shorthand canonicalization still catches a real per-side difference', () => { + const result = semanticDiffGate( + emotionNetCss({ margin: '8px 16px' }), + emotionNetCss({ marginBlock: '8px', marginInline: '99px' }), + ); + expect(result.ok).toBe(false); + }); + test('allowlist does NOT excuse a value change under the same disguise', () => { const result = semanticDiffGate( emotionNetCss({ marginLeft: '8px' }), diff --git a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js index 409188012..a5972cde6 100644 --- a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js +++ b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js @@ -107,6 +107,130 @@ function coordinate( : `${property} @ ${conditions.join(' && ')}`; } +// --- box-shorthand canonicalization ------------------------------------ + +// Emotion writes `margin: 8px 16px` (one shorthand declaration); the codemod +// emits StyleX's expanded logical form (`margin-block` / `margin-inline`). +// Compared property-by-property these look different even though they render +// identically. So before diffing we expand BOTH sides' box shorthands down +// to the four physical per-side longhands, resolving logical → physical in +// LTR (the semantic-diff checks LTR-equivalence; the allowlist owns the +// sanctioned RTL difference). margin/padding only in this slice — inset and +// border keep the allowlist path. + +const BOX_FAMILIES: $ReadOnlyArray = ['margin', 'padding']; + +/** Splits a space-separated value list, keeping parenthesized groups + * (`calc(…)`, `var(…)`) intact. */ +function splitValueList(value: string): Array { + const parts: Array = []; + let depth = 0; + let current = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch === '(') { + depth += 1; + } else if (ch === ')') { + depth -= 1; + } + if (ch === ' ' && depth === 0) { + if (current !== '') { + parts.push(current); + current = ''; + } + } else { + current += ch; + } + } + if (current !== '') { + parts.push(current); + } + return parts; +} + +/** [top, right, bottom, left] from a 1–4 value box shorthand, or null. */ +function fourSides( + values: Array, +): [string, string, string, string] | null { + switch (values.length) { + case 1: + return [values[0], values[0], values[0], values[0]]; + case 2: + return [values[0], values[1], values[0], values[1]]; + case 3: + return [values[0], values[1], values[2], values[1]]; + case 4: + return [values[0], values[1], values[2], values[3]]; + default: + return null; + } +} + +/** Expands a (property, value) into one or more physical-longhand pairs. + * Non-box properties pass through unchanged. */ +function expandToPhysical( + property: string, + value: string, +): Array<[string, string]> { + for (const fam of BOX_FAMILIES) { + if (property === fam) { + const sides = fourSides(splitValueList(value)); + return sides == null + ? [[property, value]] + : [ + [`${fam}-top`, sides[0]], + [`${fam}-right`, sides[1]], + [`${fam}-bottom`, sides[2]], + [`${fam}-left`, sides[3]], + ]; + } + if (property === `${fam}-inline` || property === `${fam}-block`) { + const vals = splitValueList(value); + const start = vals[0]; + const end = vals.length > 1 ? vals[1] : vals[0]; + return property === `${fam}-block` + ? [ + [`${fam}-top`, start], + [`${fam}-bottom`, end], + ] + : [ + [`${fam}-left`, start], + [`${fam}-right`, end], + ]; + } + switch (property) { + case `${fam}-inline-start`: + return [[`${fam}-left`, value]]; + case `${fam}-inline-end`: + return [[`${fam}-right`, value]]; + case `${fam}-block-start`: + return [[`${fam}-top`, value]]; + case `${fam}-block-end`: + return [[`${fam}-bottom`, value]]; + default: + break; + } + } + return [[property, value]]; +} + +/** Rewrites every box shorthand/logical property in a NetCss into physical + * per-side longhands so both sides of a diff share one vocabulary. */ +function canonicalizeNetCss(net: NetCss): NetCss { + const out: { [string]: NetDeclaration } = {}; + for (const coord of Object.keys(net)) { + const decl = net[coord]; + for (const [prop, val] of expandToPhysical(decl.property, decl.value)) { + out[coordinate(prop, decl.conditions)] = { + property: prop, + conditions: decl.conditions, + value: normalizeValue(val), + }; + } + } + return out; +} + function addDeclaration( target: { [string]: NetDeclaration }, property: string, @@ -349,11 +473,15 @@ export const DEFAULT_ALLOWLIST: $ReadOnlyArray = [ // --- the gate ----------------------------------------------------------- export function semanticDiffGate( - before: NetCss, - after: NetCss, + beforeRaw: NetCss, + afterRaw: NetCss, options?: { +allowlist?: $ReadOnlyArray }, ): SemanticDiffResult { const allowlist = options?.allowlist ?? DEFAULT_ALLOWLIST; + // Expand box shorthands on both sides so a shorthand and its expanded + // longhands compare equal. + const before = canonicalizeNetCss(beforeRaw); + const after = canonicalizeNetCss(afterRaw); const coordinates = new Set([...Object.keys(before), ...Object.keys(after)]); const diffs: Array = []; const allowed: Array = []; From 2fc5ef571e3e7815835786035efed0e001d408fc Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 14:22:17 -0400 Subject: [PATCH 21/32] =?UTF-8?q?codemods:=20M3b=20fixtures=20=E2=80=94=20?= =?UTF-8?q?multi-value=20margin=20&=20padding=20shorthands=20convert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../emotion/shorthand-margin/expected.js | 15 +++++++++++++++ .../emotion/shorthand-margin/input.js | 6 ++++++ .../emotion/shorthand-padding/expected.js | 16 ++++++++++++++++ .../emotion/shorthand-padding/input.js | 6 ++++++ 4 files changed, 43 insertions(+) create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/expected.js new file mode 100644 index 000000000..1f71471fe --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/expected.js @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + box: { + marginBlock: '8px', + marginInline: '16px', + color: 'navy', + }, +}); + +export default function Box() { + return
Box
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/input.js new file mode 100644 index 000000000..dd6c80c2d --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-margin/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Box() { + return
Box
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/expected.js new file mode 100644 index 000000000..d633d7b63 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/expected.js @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import * as stylex from '@stylexjs/stylex'; + +const styles = stylex.create({ + card: { + paddingBottom: '3px', + paddingLeft: '4px', + paddingRight: '2px', + paddingTop: '1px', + }, +}); + +export default function Card() { + return
Card
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/input.js new file mode 100644 index 000000000..2a2e2165a --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/shorthand-padding/input.js @@ -0,0 +1,6 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Card() { + return
Card
; +} From eb7c9db7d24c19c71a8bafff621989f68ac44ea7 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 14:50:38 -0400 Subject: [PATCH 22/32] codemods: engine support for keyframes + identifier references 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 --- .../@stylexjs/codemods/__tests__/emit-test.js | 65 ++++++++++++++ packages/@stylexjs/codemods/src/core/emit.js | 90 +++++++++++-------- packages/@stylexjs/codemods/src/core/ir.js | 11 ++- .../@stylexjs/codemods/src/core/normalize.js | 19 ++-- .../@stylexjs/codemods/src/core/rewriter.js | 5 ++ 5 files changed, 145 insertions(+), 45 deletions(-) diff --git a/packages/@stylexjs/codemods/__tests__/emit-test.js b/packages/@stylexjs/codemods/__tests__/emit-test.js index ff1a63939..30e270a8f 100644 --- a/packages/@stylexjs/codemods/__tests__/emit-test.js +++ b/packages/@stylexjs/codemods/__tests__/emit-test.js @@ -234,3 +234,68 @@ describe('buildFileIR', () => { expect(bindings).toEqual(['badge']); }); }); + +describe('emitFileIR — references and keyframes', () => { + test('a reference value emits a $$ref sentinel (rendered as a bare identifier)', () => { + const ir: FileIR = { + rules: [ + { + name: 'spinner', + atoms: [ + { + property: 'animationName', + conditions: [], + value: { kind: 'reference', name: 'spin' }, + }, + ], + }, + ], + keyframes: [], + }; + expect(emitFileIR(ir).rules[0].style).toEqual({ + animationName: { $$ref: 'spin' }, + }); + }); + + test('keyframes emit as named frame objects', () => { + const ir: FileIR = { + rules: [], + keyframes: [ + { + name: 'spin', + frames: [ + { + selector: 'from', + atoms: [ + { + property: 'opacity', + conditions: [], + value: { kind: 'static', value: 0 }, + }, + ], + }, + { + selector: 'to', + atoms: [ + { + property: 'opacity', + conditions: [], + value: { kind: 'static', value: 1 }, + }, + ], + }, + ], + }, + ], + }; + expect(emitFileIR(ir).keyframes).toEqual([ + { + name: 'spin', + frames: [ + { selector: 'from', style: { opacity: 0 } }, + { selector: 'to', style: { opacity: 1 } }, + ], + }, + ]); + }); +}); diff --git a/packages/@stylexjs/codemods/src/core/emit.js b/packages/@stylexjs/codemods/src/core/emit.js index 8624a7ec1..d1349784d 100644 --- a/packages/@stylexjs/codemods/src/core/emit.js +++ b/packages/@stylexjs/codemods/src/core/emit.js @@ -29,14 +29,18 @@ import { conditionKey } from './ir'; /** * A value in a `stylex.create` entry: a static value, a fallback array - * (StyleX's `firstThatWorks`), or a nested condition object. + * (StyleX's `firstThatWorks`), an identifier reference (rendered as a bare + * identifier, e.g. `animationName: spin`), or a nested condition object. */ export type EmittedValue = | string | number | null | $ReadOnlyArray + | EmittedRef | EmittedConditions; +/** Sentinel for a bare-identifier reference; `$$ref` is the identifier. */ +export type EmittedRef = { +$$ref: string }; export type EmittedConditions = { +[condition: string]: EmittedValue }; /** A `stylex.create` entry as plain data: property -> value. */ @@ -47,8 +51,15 @@ export type EmittedRule = { +style: EmittedStyle, }; +/** An emitted `stylex.keyframes` declaration: a variable name and its frames. */ +export type EmittedKeyframes = { + +name: string, + +frames: $ReadOnlyArray<{ +selector: string, +style: EmittedStyle }>, +}; + export type EmitResult = { +rules: $ReadOnlyArray, + +keyframes: $ReadOnlyArray, /** bindings[i] is the create key for FileIR.rules[i]. */ +bindings: $ReadOnlyArray, }; @@ -81,18 +92,16 @@ export function sanitizeKey(hint: string): string { return IDENTIFIER.test(key) && !RESERVED.has(key) ? key : 'styles'; } -function staticValue(value: Value, rule: StyleRule): string | number | null { - if (value.kind === 'first-that-works') { - // A fallback array is itself a leaf; handled by the caller. - throw new EmitError(`rule '${rule.name}': unexpected fallback array`); +function leafValue(value: Value): EmittedValue { + switch (value.kind) { + case 'first-that-works': + return value.values; + case 'reference': + return { $$ref: value.name }; + case 'static': + default: + return value.value; } - return value.value; -} - -function leafValue(value: Value, rule: StyleRule): EmittedValue { - return value.kind === 'first-that-works' - ? value.values - : staticValue(value, rule); } /** @@ -192,20 +201,40 @@ function emitProperty( ): EmittedValue { // Flat fast-path: a single unconditional atom stays a plain value. if (atoms.length === 1 && atoms[0].conditions.length === 0) { - return leafValue(atoms[0].value, rule); + return leafValue(atoms[0].value); } const root: MutableTree = {}; for (const atom of atoms) { insertAtPath( root, canonicalPath(atom.conditions, hoverGuard), - leafValue(atom.value, rule), + leafValue(atom.value), rule, ); } return normalizeTree(root); } +/** Groups a rule's atoms by property (the flip) into one emitted style + * object, alphabetically ordered for stylex/sort-keys. */ +function emitStyleObject(rule: StyleRule, hoverGuard: boolean): EmittedStyle { + const byProperty: Map> = new Map(); + for (const atom of rule.atoms) { + const list = byProperty.get(atom.property) ?? []; + list.push(atom); + byProperty.set(atom.property, list); + } + const style: { [string]: EmittedValue } = {}; + for (const property of [...byProperty.keys()].sort()) { + style[property] = emitProperty( + byProperty.get(property) ?? [], + rule, + hoverGuard, + ); + } + return style; +} + export function emitFileIR(ir: FileIR, options?: EmitOptions): EmitResult { const hoverGuard = options?.hoverGuard ?? true; const usedKeys = new Set(); @@ -213,24 +242,7 @@ export function emitFileIR(ir: FileIR, options?: EmitOptions): EmitResult { const bindings: Array = []; for (const rule of ir.rules) { - // Group atoms by property, preserving source order within each property. - const byProperty: Map> = new Map(); - for (const atom of rule.atoms) { - const list = byProperty.get(atom.property) ?? []; - list.push(atom); - byProperty.set(atom.property, list); - } - // Alphabetical property order satisfies stylex/sort-keys with zero - // autofixes. - const style: { [string]: EmittedValue } = {}; - for (const property of [...byProperty.keys()].sort()) { - style[property] = emitProperty( - byProperty.get(property) ?? [], - rule, - hoverGuard, - ); - } - + const style = emitStyleObject(rule, hoverGuard); const base = sanitizeKey(rule.name); let key = base; for (let n = 2; usedKeys.has(key); n++) { @@ -241,8 +253,14 @@ export function emitFileIR(ir: FileIR, options?: EmitOptions): EmitResult { bindings.push(key); } - if (ir.keyframes.length > 0) { - throw new EmitError('keyframes are not emittable until M3'); - } - return { rules, bindings }; + const keyframes: Array = ir.keyframes.map((kf) => ({ + name: kf.name, + frames: kf.frames.map((frame) => ({ + selector: frame.selector, + // A frame is a flat style object; reuse the same grouping machinery. + style: emitStyleObject({ name: kf.name, atoms: frame.atoms }, hoverGuard), + })), + })); + + return { rules, keyframes, bindings }; } diff --git a/packages/@stylexjs/codemods/src/core/ir.js b/packages/@stylexjs/codemods/src/core/ir.js index d1bedb2d4..1a0e6a84c 100644 --- a/packages/@stylexjs/codemods/src/core/ir.js +++ b/packages/@stylexjs/codemods/src/core/ir.js @@ -34,16 +34,19 @@ export type Condition = | { +kind: 'at-rule', +rule: string }; // e.g. '@media (min-width: 600px)' /** - * The value of an atom. `static` is the only variant in v1.0; a - * `dynamic` variant (props-driven, -> StyleX function-form create) is the - * planned v1.1 addition. + * The value of an atom. `static` is the base variant; `first-that-works` is + * a fallback array; `reference` is an identifier reference (e.g. + * `animationName` pointing at a `stylex.keyframes` variable). A `dynamic` + * variant (props-driven, -> StyleX function-form create) is the planned + * v1.1 addition. */ export type Value = | { +kind: 'static', +value: string | number | null } | { +kind: 'first-that-works', +values: $ReadOnlyArray, - }; + } + | { +kind: 'reference', +name: string }; /** * The unit of the IR: one property, under zero or more conditions, diff --git a/packages/@stylexjs/codemods/src/core/normalize.js b/packages/@stylexjs/codemods/src/core/normalize.js index 9aa06f536..c2b30415d 100644 --- a/packages/@stylexjs/codemods/src/core/normalize.js +++ b/packages/@stylexjs/codemods/src/core/normalize.js @@ -45,6 +45,11 @@ const PHYSICAL_TO_LOGICAL: $ReadOnly<{ [string]: string }> = { right: 'insetInlineEnd', }; +function mapAtom(atom: Atom): Atom { + const mapped = PHYSICAL_TO_LOGICAL[atom.property]; + return mapped == null ? atom : { ...atom, property: mapped }; +} + export function normalizeFileIR( ir: FileIR, options?: NormalizeOptions, @@ -55,10 +60,14 @@ export function normalizeFileIR( } const rules: $ReadOnlyArray = ir.rules.map((rule) => ({ name: rule.name, - atoms: rule.atoms.map((atom: Atom) => { - const mapped = PHYSICAL_TO_LOGICAL[atom.property]; - return mapped == null ? atom : { ...atom, property: mapped }; - }), + atoms: rule.atoms.map(mapAtom), + })); + const keyframes = ir.keyframes.map((kf) => ({ + name: kf.name, + frames: kf.frames.map((frame) => ({ + selector: frame.selector, + atoms: frame.atoms.map(mapAtom), + })), })); - return { rules, keyframes: ir.keyframes }; + return { rules, keyframes }; } diff --git a/packages/@stylexjs/codemods/src/core/rewriter.js b/packages/@stylexjs/codemods/src/core/rewriter.js index 6a1a7abb3..8e6d60f8e 100644 --- a/packages/@stylexjs/codemods/src/core/rewriter.js +++ b/packages/@stylexjs/codemods/src/core/rewriter.js @@ -75,6 +75,11 @@ function valueAst(j: $FlowFixMe, value: EmittedValue): $FlowFixMe { return j.arrayExpression(value.map((v) => j.literal(v))); } if (value != null && typeof value === 'object') { + // A reference sentinel renders as a bare identifier (e.g. `animationName: + // spin`), not a string literal. + if (typeof value.$$ref === 'string') { + return j.identifier(value.$$ref); + } return objectAst(j, value); } return j.literal(value); From 8f2cc36a425690fa481e8f3a257f090c622bf079 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 14:51:08 -0400 Subject: [PATCH 23/32] codemods: Emotion adapter converts object-form keyframes 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 --- .../codemods/src/adapters/emotion/detect.js | 85 ++++++++++++++++ .../codemods/src/adapters/emotion/imports.js | 26 +++-- .../codemods/src/adapters/emotion/read.js | 95 +++++++++++++++++- .../src/adapters/emotion/transform.js | 98 ++++++++++++++++--- 4 files changed, 281 insertions(+), 23 deletions(-) diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js index c44ac5156..7a8a8e791 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js @@ -133,3 +133,88 @@ export function detectSites( return { sites, blockers }; } + +export type KeyframesSite = { + +callPath: $FlowFixMe, // CallExpression path (keyframes({...})) + +objectNode: $FlowFixMe, // the frames ObjectExpression + +varName: string, // the `const = keyframes(...)` binding +}; + +export type KeyframesDetection = { + +sites: Array, + +names: Set, + +blockers: Array, +}; + +/** + * Finds `const NAME = keyframes({ ... })` declarations. Only the object form + * bound to a simple `const` is convertible; anything else (tagged template, + * inline, reassignment) is a blocker. + */ +export function detectKeyframes( + j: $FlowFixMe, + root: $FlowFixMe, + keyframesLocalName: string | null, +): KeyframesDetection { + const sites: Array = []; + const names: Set = new Set(); + const blockers: Array = []; + if (keyframesLocalName == null) { + return { sites, names, blockers }; + } + + const siteCallees = new Set<$FlowFixMe>(); + root + .find(j.CallExpression) + .filter( + (path: $FlowFixMe) => + path.node.callee.type === 'Identifier' && + path.node.callee.name === keyframesLocalName, + ) + .forEach((path: $FlowFixMe) => { + const declarator = path.parent.node; + const isSimpleConstBinding = + declarator.type === 'VariableDeclarator' && + declarator.init === path.node && + declarator.id.type === 'Identifier'; + const arg = path.node.arguments[0]; + if ( + !isSimpleConstBinding || + path.node.arguments.length !== 1 || + arg.type !== 'ObjectExpression' + ) { + blockers.push( + 'keyframes() must be an object bound to a const to convert ' + + '(tagged-template or inline keyframes land later)', + ); + return; + } + siteCallees.add(path.node.callee); + names.add(declarator.id.name); + sites.push({ + callPath: path, + objectNode: arg, + varName: declarator.id.name, + }); + }); + + // Any other use of the keyframes identifier (not a convertible call, not + // the import) means we cannot fully remove it — refuse. + root + .find(j.Identifier, { name: keyframesLocalName }) + .forEach((path: $FlowFixMe) => { + const parentNode = path.parent.node; + if ( + parentNode.type === 'ImportSpecifier' || + siteCallees.has(path.node) || + (parentNode.type === 'Property' && parentNode.key === path.node) + ) { + return; + } + blockers.push( + `'${keyframesLocalName}' is used in an unsupported position`, + ); + }); + + return { sites, names, blockers }; +} diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/imports.js b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js index a5090651d..27bf67ac3 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/imports.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js @@ -20,14 +20,19 @@ const PRAGMA_PATTERN = /@jsxImportSource\s+@emotion\/react/; export type EmotionWiring = { +hasPragma: boolean, +cssLocalName: string | null, + +keyframesLocalName: string | null, +blockers: Array, }; +// Named @emotion/react imports the adapter knows how to convert. +const CONVERTIBLE_IMPORTS = new Set(['css', 'keyframes']); + export function analyzeEmotionWiring( j: $FlowFixMe, root: $FlowFixMe, ): EmotionWiring { let cssLocalName: string | null = null; + let keyframesLocalName: string | null = null; const blockers: Array = []; root.find(j.ImportDeclaration).forEach((path: $FlowFixMe) => { @@ -40,15 +45,16 @@ export function analyzeEmotionWiring( return; } for (const specifier of path.node.specifiers ?? []) { - if ( - specifier.type === 'ImportSpecifier' && - specifier.imported.name === 'css' - ) { + const imported = + specifier.type === 'ImportSpecifier' ? specifier.imported.name : null; + if (imported === 'css') { cssLocalName = specifier.local.name; + } else if (imported === 'keyframes') { + keyframesLocalName = specifier.local.name; } else { blockers.push( `'@emotion/react' import of '${ - specifier.imported?.name ?? specifier.local?.name ?? '?' + imported ?? specifier.local?.name ?? '?' }' is not convertible yet`, ); } @@ -58,6 +64,7 @@ export function analyzeEmotionWiring( return { hasPragma: PRAGMA_PATTERN.test(findPragmaText(j, root) ?? ''), cssLocalName, + keyframesLocalName, blockers, }; } @@ -94,9 +101,10 @@ export function removePragma(j: $FlowFixMe, root: $FlowFixMe): void { } /** - * Removes the `css` specifier from the `@emotion/react` import (the whole - * declaration when nothing else remains), transplanting any non-pragma - * comments onto the next statement so file headers survive. + * Removes the converted specifiers (`css`, `keyframes`) from the + * `@emotion/react` import (the whole declaration when nothing else remains), + * transplanting any non-pragma comments onto the next statement so file + * headers survive. */ export function removeCssImport(j: $FlowFixMe, root: $FlowFixMe): void { root.find(j.ImportDeclaration).forEach((path: $FlowFixMe) => { @@ -107,7 +115,7 @@ export function removeCssImport(j: $FlowFixMe, root: $FlowFixMe): void { (specifier: $FlowFixMe) => !( specifier.type === 'ImportSpecifier' && - specifier.imported.name === 'css' + CONVERTIBLE_IMPORTS.has(specifier.imported.name) ), ); if (remaining.length > 0) { diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/read.js b/packages/@stylexjs/codemods/src/adapters/emotion/read.js index 2439361f3..f2e69ac33 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/read.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/read.js @@ -20,7 +20,7 @@ * referee cannot yet arbitrate. */ -import type { Condition, Value } from '../../core/ir'; +import type { Atom, Condition, KeyframesRule, Value } from '../../core/ir'; import type { Declaration } from '../../core/buildIR'; import { atomCoordinate } from '../../core/ir'; import type { StyleSite } from './detect'; @@ -160,7 +160,11 @@ function findShorthandOverlap(properties: Array): string | null { return null; } -export function readSite(site: StyleSite): ReadSite { +export function readSite( + site: StyleSite, + keyframesNames?: $ReadOnlySet, +): ReadSite { + const knownKeyframes = keyframesNames ?? new Set(); const declarations: Array = []; const cssObject: { [string]: mixed } = {}; let label: string | null = null; @@ -219,6 +223,23 @@ export function readSite(site: StyleSite): ReadSite { if (!IDENTIFIER.test(key) && !/^[a-zA-Z-]+$/.test(key)) { return `un-convertible style key '${key}'`; } + + // `animationName: ` — a reference, not a static value. + // Omitted from the mirror; the keyframes content is diffed separately + // and the generated animation-name is allowlisted. + if ( + key === 'animationName' && + valueNode.type === 'Identifier' && + knownKeyframes.has(valueNode.name) + ) { + declarations.push({ + property: key, + value: { kind: 'reference', name: valueNode.name }, + conditions, + }); + continue; + } + const value = valueOf(valueNode); if (value == null) { return ( @@ -285,6 +306,76 @@ export function readSite(site: StyleSite): ReadSite { }; } +export type ReadKeyframes = + | { + +ok: true, + +rule: KeyframesRule, + +framesObject: { +[selector: string]: { +[string]: mixed } }, + } + | { +ok: false, +blocker: string }; + +const FRAME_SELECTOR = /^(from|to|-?\d+(\.\d+)?%)$/; + +/** Reads a `keyframes({ from: {...}, to: {...} })` object into a + * KeyframesRule plus a plain mirror (for the semantic-diff "before"). Frames + * are flat style objects — nested conditions inside a frame are refused. */ +export function readKeyframes( + varName: string, + objectNode: $FlowFixMe, +): ReadKeyframes { + const frames: Array<{ selector: string, atoms: Array }> = []; + const framesObject: { [selector: string]: { [string]: mixed } } = {}; + + for (const property of objectNode.properties) { + if (property.type !== 'Property' && property.type !== 'ObjectProperty') { + return { ok: false, blocker: 'spread in keyframes object' }; + } + const selector = propertyKey(property.key); + if (selector == null || !FRAME_SELECTOR.test(selector)) { + return { + ok: false, + blocker: `keyframes selector '${String(selector)}' is not from/to/%`, + }; + } + if (property.value.type !== 'ObjectExpression') { + return { + ok: false, + blocker: `keyframes frame '${selector}' is not an object`, + }; + } + const atoms: Array = []; + const mirror: { [string]: mixed } = {}; + for (const decl of property.value.properties) { + if (decl.type !== 'Property' && decl.type !== 'ObjectProperty') { + return { ok: false, blocker: 'spread in keyframes frame' }; + } + const key = propertyKey(decl.key); + if (key == null || decl.computed) { + return { + ok: false, + blocker: `un-analyzable key in frame '${selector}'`, + }; + } + const value = valueOf(decl.value); + if (value == null) { + return { + ok: false, + blocker: `value of '${key}' in frame '${selector}' is not static`, + }; + } + atoms.push({ property: key, conditions: [], value }); + mirror[key] = plainOf(value); + } + frames.push({ selector, atoms }); + framesObject[selector] = mirror; + } + + if (frames.length === 0) { + return { ok: false, blocker: 'empty keyframes object' }; + } + return { ok: true, rule: { name: varName, frames }, framesObject }; +} + function enclosingComponentName(site: StyleSite): string | null { for (let path = site.attrPath; path != null; path = path.parent) { const node = path.node; diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js index f27c682ce..ef3c3a3b8 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -19,7 +19,11 @@ import { buildFileIR } from '../../core/buildIR'; import { emitFileIR } from '../../core/emit'; -import type { EmittedRule } from '../../core/emit'; +import type { + EmittedRule, + EmittedStyle, + EmittedKeyframes, +} from '../../core/emit'; import { normalizeFileIR } from '../../core/normalize'; import { postprocess } from '../../core/postprocess'; import { checkRule } from '../../core/referee'; @@ -35,8 +39,8 @@ import { removeCssImport, insertStylexImport, } from './imports'; -import { detectSites } from './detect'; -import { readSite } from './read'; +import { detectSites, detectKeyframes } from './detect'; +import { readSite, readKeyframes } from './read'; import type { PlainStyleObject } from './read'; import { rewriteSite } from './rewriteSites'; @@ -45,6 +49,9 @@ export type TransformResult = +status: 'converted', +code: string, +sites: $ReadOnlyArray<{ +key: string, +cssObject: PlainStyleObject }>, + +keyframes: $ReadOnlyArray<{ + +framesObject: { +[selector: string]: { +[string]: mixed } }, + }>, } | { +status: 'skipped', +reasons: $ReadOnlyArray } | { +status: 'unchanged' }; @@ -71,22 +78,41 @@ export function transformEmotionFile( }); const wiring = analyzeEmotionWiring(j, root); - if (!wiring.hasPragma && wiring.cssLocalName == null) { + if ( + !wiring.hasPragma && + wiring.cssLocalName == null && + wiring.keyframesLocalName == null + ) { return { status: 'unchanged' }; } + // Keyframes first, so css sites can reference them by name. + const kfDetection = detectKeyframes(j, root, wiring.keyframesLocalName); + const kfReads = kfDetection.sites.map((s) => + readKeyframes(s.varName, s.objectNode), + ); + const detection = detectSites(j, root, wiring.cssLocalName); - const blockers = [...wiring.blockers, ...detection.blockers]; - const reads = detection.sites.map(readSite); + const blockers = [ + ...wiring.blockers, + ...detection.blockers, + ...kfDetection.blockers, + ]; + const reads = detection.sites.map((s) => readSite(s, kfDetection.names)); for (const read of reads) { if (!read.ok) { blockers.push(read.blocker); } } + for (const kf of kfReads) { + if (!kf.ok) { + blockers.push(kf.blocker); + } + } if (blockers.length > 0) { return { status: 'skipped', reasons: blockers }; } - if (detection.sites.length === 0) { + if (detection.sites.length === 0 && kfDetection.sites.length === 0) { return { status: 'unchanged' }; } @@ -97,11 +123,18 @@ export function transformEmotionFile( } return { nameHint: read.nameHint, declarations: read.declarations }; }); + const keyframeRules = kfReads.map((kf) => { + if (!kf.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return kf.rule; + }); // L6 Normalize runs before the referee so every downstream layer sees one // vocabulary (physical→logical is a sanctioned RTL change). - const fileIR = normalizeFileIR(buildFileIR(groups), { - logicalProperties: options?.logicalProperties ?? true, - }); + const fileIR = normalizeFileIR( + { rules: buildFileIR(groups).rules, keyframes: keyframeRules }, + { logicalProperties: options?.logicalProperties ?? true }, + ); // L5 Referee: convert only when Emotion's cascade and StyleX's priority // agree on every simultaneously-active condition; otherwise refuse. @@ -110,7 +143,7 @@ export function transformEmotionFile( if (conflicts.length > 0) { return { status: 'skipped', reasons: conflicts }; } - const { rules, bindings } = emitFileIR( + const { rules, keyframes, bindings } = emitFileIR( { rules: refereed.map((r) => { if (!r.ok) { @@ -128,7 +161,10 @@ export function transformEmotionFile( detection.sites.forEach((site, i) => { rewriteSite(j, site, stylesLocalName, bindings[i]); }); - insertRegistry(j, root, detection.sites[0], stylesLocalName, rules); + rewriteKeyframes(j, kfDetection.sites, keyframes); + if (rules.length > 0) { + insertRegistry(j, root, detection.sites[0], stylesLocalName, rules); + } insertStylexImport(j, root); removeCssImport(j, root); removePragma(j, root); @@ -154,9 +190,47 @@ export function transformEmotionFile( } return { key: bindings[i], cssObject: read.cssObject }; }), + keyframes: kfReads.map((kf) => { + if (!kf.ok) { + throw new Error('unreachable: blockers were checked above'); + } + return { framesObject: kf.framesObject }; + }), }; } +/** + * Rewrites each `keyframes({...})` call in place to + * `stylex.keyframes({...})`, replacing the frames object with the emitted + * (normalized) one. Emitted keyframes are matched to detected sites by the + * bound variable name. + */ +function rewriteKeyframes( + j: $FlowFixMe, + sites: $ReadOnlyArray<{ +callPath: $FlowFixMe, +varName: string, ... }>, + emitted: $ReadOnlyArray, +): void { + const byName: Map = new Map( + emitted.map((kf) => [kf.name, kf]), + ); + for (const site of sites) { + const kf = byName.get(site.varName); + if (kf == null) { + continue; + } + const framesObject: { [string]: EmittedStyle } = {}; + for (const frame of kf.frames) { + framesObject[frame.selector] = frame.style; + } + j(site.callPath).replaceWith( + j.callExpression( + j.memberExpression(j.identifier('stylex'), j.identifier('keyframes')), + [styleToObjectAst(j, framesObject)], + ), + ); + } +} + /** `styles`, or a numbered variant if the file already uses that name. */ function pickStylesName(j: $FlowFixMe, root: $FlowFixMe): string { const taken = (name: string) => root.find(j.Identifier, { name }).size() > 0; From 6d8a41a20973f255ca2eda02507fdd603e7e54da Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 14:51:16 -0400 Subject: [PATCH 24/32] codemods: semantic-diff compares @keyframes frame contents; allowlist 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 --- .../emotion/keyframes-spin/expected.js | 23 +++++ .../emotion/keyframes-spin/input.js | 14 +++ .../__tests__/emotion-fixtures-test.js | 29 ++++++ .../codemods/src/core/gates/semanticDiff.js | 90 +++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/expected.js new file mode 100644 index 000000000..23e4d0ed6 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/expected.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +const spin = stylex.keyframes({ + from: { + transform: 'rotate(0deg)', + }, + + to: { + transform: 'rotate(360deg)', + }, +}); + +const styles = stylex.create({ + spinner: { + animationDuration: '1s', + animationName: spin, + }, +}); + +export default function Spinner() { + return
Loading
; +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/input.js new file mode 100644 index 000000000..96adfe295 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/keyframes-spin/input.js @@ -0,0 +1,14 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { keyframes } from '@emotion/react'; + +const spin = keyframes({ + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, +}); + +export default function Spinner() { + return ( +
Loading
+ ); +} diff --git a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js index 3cb1f4b25..f0f8e07bf 100644 --- a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js +++ b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js @@ -31,12 +31,29 @@ import { semanticDiffGate, netCssFromSerializedCss, netCssFromStylexMetadata, + keyframesFromStylexMetadata, + parseFrames, } from '../src/core/gates/semanticDiff'; import { transformEmotionFile } from '../src/adapters/emotion/transform'; import { loadFixtures, formatWithPrettier } from './utils/harness'; const UPDATE = process.env.UPDATE_STYLEX_CODEMOD_FIXTURES === '1'; +/** Deterministic JSON with recursively-sorted keys, for set comparison. */ +function canonicalJson(value: mixed): string { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null'; + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]`; + } + const obj: { +[string]: mixed } = value; + return `{${Object.keys(obj) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`) + .join(',')}}`; +} + const fixtures = loadFixtures('emotion'); test('there is at least one fixture pair', () => { @@ -132,6 +149,18 @@ describe.each(fixtures.map((f) => [f.name, f]))( throw new Error(JSON.stringify(diff.diffs, null, 2)); } expect(diff.ok).toBe(true); + + // Keyframes: the generated animation-name differs, so the frame + // CONTENTS are compared directly (Emotion serializer vs StyleX + // @keyframes metadata), as an order-independent multiset. + const emotionFrames = result.keyframes + .map((kf) => parseFrames(serializeStyles([kf.framesObject]).styles)) + .map(canonicalJson) + .sort(); + const stylexFrames = keyframesFromStylexMetadata(compiled.metadata) + .map(canonicalJson) + .sort(); + expect(stylexFrames).toEqual(emotionFrames); }); }, ); diff --git a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js index a5972cde6..c74b4439b 100644 --- a/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js +++ b/packages/@stylexjs/codemods/src/core/gates/semanticDiff.js @@ -349,11 +349,87 @@ export function netCssFromStylexMetadata(metadata: mixed): NetCss { const out: { [string]: NetDeclaration } = {}; for (const { ltr } of entries) { + // @keyframes rules are compared separately (frame contents), not as + // per-property net CSS. + if (ltr.trim().startsWith('@keyframes')) { + continue; + } parseStylexRule(ltr, out); } return out; } +export type FrameMap = { +[selector: string]: { +[property: string]: string } }; + +/** Extracts `@keyframes NAME{ from{...} to{...} }` rules from StyleX + * metadata as normalized frame maps (name-agnostic). */ +export function keyframesFromStylexMetadata( + metadata: mixed, +): $ReadOnlyArray { + const rules = + metadata != null && + typeof metadata === 'object' && + Array.isArray(metadata.stylex) + ? metadata.stylex + : Array.isArray(metadata) + ? metadata + : []; + const out: Array = []; + for (const rule of rules) { + let ltr = null; + if (Array.isArray(rule)) { + const styles = rule[1]; + if ( + styles != null && + typeof styles === 'object' && + typeof styles.ltr === 'string' + ) { + ltr = styles.ltr; + } + } + if (ltr == null || !ltr.trim().startsWith('@keyframes')) { + continue; + } + const brace = ltr.indexOf('{'); + const inner = ltr.slice(brace + 1, ltr.lastIndexOf('}')); + out.push(parseFrames(inner)); + } + return out; +} + +/** Parses `from{...}to{...}0%{...}` frame text into a normalized frame map. */ +export function parseFrames(css: string): FrameMap { + const frames: { [string]: { [string]: string } } = {}; + const blockRe = /([^{}]+)\{([^{}]*)\}/g; + for (const match of css.trim().matchAll(blockRe)) { + const rawSelector = match[1]; + const body = match[2]; + if (rawSelector == null || body == null) { + continue; + } + const selector = rawSelector.trim().toLowerCase(); + const decls: { [string]: string } = {}; + for (const chunk of body.split(';')) { + const decl = chunk.trim(); + if (decl === '') { + continue; + } + const colon = decl.indexOf(':'); + if (colon <= 0) { + throw new UnsupportedCssError(`keyframe declaration '${decl}'`); + } + decls[decl.slice(0, colon).trim().toLowerCase()] = normalizeValue( + decl.slice(colon + 1), + ); + } + frames[selector] = decls; + } + if (Object.keys(frames).length === 0) { + throw new UnsupportedCssError(`no keyframe blocks in '${css}'`); + } + return frames; +} + function parseStylexRule(ltr: string, out: { [string]: NetDeclaration }): void { let rest = ltr.trim(); const atRules: Array = []; @@ -465,9 +541,23 @@ export const allowHoverGuard: AllowlistRule = (entry, before, after) => { return false; }; +/** + * Sanctioned: `animation-name` present only on the after side. Emotion and + * StyleX generate different opaque keyframes names, so the codemod omits the + * reference from the Emotion "before" and the frame CONTENTS are verified + * separately (see `keyframesFromStylexMetadata`). This only ever fires for a + * converted keyframes reference — a literal animationName string stays in the + * before and is diffed normally. + */ +export const allowGeneratedAnimationName: AllowlistRule = (entry) => + entry.property === 'animation-name' && + entry.beforeValue == null && + entry.afterValue != null; + export const DEFAULT_ALLOWLIST: $ReadOnlyArray = [ allowPhysicalToLogical, allowHoverGuard, + allowGeneratedAnimationName, ]; // --- the gate ----------------------------------------------------------- From 6cb0fa1de5d25e21e68d8ad586321c243846da0b Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 21:31:14 -0400 Subject: [PATCH 25/32] =?UTF-8?q?codemods:=20ADR-0001=20=E2=80=94=20defer?= =?UTF-8?q?=20theme=20tokens=20to=20M6=20(a=20trusted=20transformation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/@stylexjs/codemods/README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/@stylexjs/codemods/README.md b/packages/@stylexjs/codemods/README.md index 77dc801bf..2f5721dc4 100644 --- a/packages/@stylexjs/codemods/README.md +++ b/packages/@stylexjs/codemods/README.md @@ -4,9 +4,12 @@ Codemods for migrating styling libraries to [StyleX](https://stylexjs.com) — Emotion first, built as **one library-agnostic engine with swappable per-library adapters**. -> **Status: pre-release scaffold (M0).** The correctness harness — fixture -> tests plus three gates (compile, lint, semantic-diff) — is in place and -> proven; transforms land milestone by milestone. Nothing here is published +> **Status: pre-release, milestone M3 of the v1.0 (MVP) ladder.** The +> correctness harness — fixture tests plus three gates (compile, lint, +> semantic-diff) — is in place and proven; transforms land milestone by +> milestone. Converting today: static styles, self-targeting conditions +> (pseudo-classes/elements, media queries), physical→logical properties, +> multi-value shorthands, and object-form keyframes. Nothing here is published > yet, and the package name/location is pending maintainer confirmation. ## Principles @@ -25,9 +28,14 @@ per-library adapters**. | Bucket | Patterns | | --- | --- | -| Converts | static `css={{…}}` / `css({})` / object-form `styled.div({})`; self-targeting pseudo-classes/elements; media queries; object-form `keyframes`; fallback arrays; shorthands; mapped theme tokens | -| Flags | template literals; dynamic styles; `styled(Component)`; out-of-element selectors; ``; `shouldForwardProp`; unmapped tokens; `!important` | -| Refuses | any file where partial conversion could change rendering | +| 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; ``; `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. ## Compatibility From 9139cd1d40144c0c1f014704b23702e50eea0888 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 22:14:12 -0400 Subject: [PATCH 26/32] =?UTF-8?q?codemods:=20scoped-postprocess=20plumbing?= =?UTF-8?q?=20=E2=80=94=20postprocess=20excludeRules,=20emit=20reservedKey?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../expected.js | 0 .../input.js | 0 packages/@stylexjs/codemods/src/core/emit.js | 5 ++- .../codemods/src/core/postprocess.js | 36 ++++++++++++------- 4 files changed, 28 insertions(+), 13 deletions(-) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-existing-stylex => merge-existing-registry}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-existing-stylex => merge-existing-registry}/input.js (100%) diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-existing-stylex/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/input.js diff --git a/packages/@stylexjs/codemods/src/core/emit.js b/packages/@stylexjs/codemods/src/core/emit.js index d1349784d..5731c8e87 100644 --- a/packages/@stylexjs/codemods/src/core/emit.js +++ b/packages/@stylexjs/codemods/src/core/emit.js @@ -67,6 +67,9 @@ export type EmitResult = { export type EmitOptions = { /** Wrap `:hover` in `@media (hover: hover)` (default true). */ +hoverGuard?: boolean, + /** Style-name keys already taken (e.g. a pre-existing registry we merge + * into); emitted keys avoid collisions with these. */ + +reservedKeys?: $ReadOnlySet, }; export class EmitError extends Error { @@ -237,7 +240,7 @@ function emitStyleObject(rule: StyleRule, hoverGuard: boolean): EmittedStyle { export function emitFileIR(ir: FileIR, options?: EmitOptions): EmitResult { const hoverGuard = options?.hoverGuard ?? true; - const usedKeys = new Set(); + const usedKeys = new Set(options?.reservedKeys ?? []); const rules: Array = []; const bindings: Array = []; diff --git a/packages/@stylexjs/codemods/src/core/postprocess.js b/packages/@stylexjs/codemods/src/core/postprocess.js index 7462f2dcb..62360a540 100644 --- a/packages/@stylexjs/codemods/src/core/postprocess.js +++ b/packages/@stylexjs/codemods/src/core/postprocess.js @@ -8,21 +8,22 @@ */ /** - * L10 — Postprocess. Runs StyleX's OWN eslint autofixes over the emitted - * output, so the result passes `@stylexjs/eslint-plugin` at error with zero - * autofixes remaining (Meta's golden rule) — and, crucially, so key - * ordering and shorthand handling exactly match StyleX's canonical form - * rather than a heuristic we maintain. + * L10 — Postprocess. Runs StyleX's OWN eslint autofixes over emitted output, + * so the result passes `@stylexjs/eslint-plugin` at error with zero autofixes + * remaining (Meta's golden rule) — and, crucially, so key ordering and + * shorthand handling exactly match StyleX's canonical form rather than a + * heuristic we maintain. * - * M3: run file-wide. This is safe *now* because a converted file has no - * pre-existing user `stylex.create` (files that do are refused upstream), - * so the only nodes the autofix can touch are the ones we emitted. M4 adds - * the scoping needed once we merge into a user's existing registry. + * M4: the caller runs this on a SCOPED snippet containing only the emitted + * `stylex.create`/`stylex.keyframes` (plus usage stubs), never the whole + * file — so a user's pre-existing StyleX code is never linted or reordered + * (proven by `__tests__/scoped-postprocess-test.js`). The fixed objects are + * then spliced back into the file. * * The sort-keys autofix can reorder sibling media queries, which the StyleX - * compiler treats as semantic — but the semantic-diff gate runs on this - * function's OUTPUT, so any reorder that changes rendering is caught (the - * file is refused) rather than shipped. + * compiler treats as semantic — but the semantic-diff gate runs on the final + * output, so any reorder that changes rendering is caught (file refused) + * rather than shipped. * * Residual (unfixable) errors mean the output is not clean; the caller * refuses the whole file. @@ -37,17 +38,28 @@ export type PostprocessResult = { +residualErrors: $ReadOnlyArray, }; +export type PostprocessOptions = { + /** Rule short-names to omit (e.g. 'no-unused' when linting a snippet whose + * styles are used elsewhere). */ + +excludeRules?: $ReadOnlyArray, +}; + const PARSER_NAME = 'hermes-eslint'; export function postprocess( code: string, filename: string = 'file.js', + options?: PostprocessOptions, ): PostprocessResult { + const excluded = new Set(options?.excludeRules ?? []); const linter = new Linter(); linter.defineParser(PARSER_NAME, hermesEslint); const ruleMap: { +[string]: mixed } = stylexRules; const config: { [string]: 'error' } = {}; for (const ruleName of Object.keys(ruleMap)) { + if (excluded.has(ruleName)) { + continue; + } const qualified = `@stylexjs/${ruleName}`; linter.defineRule(qualified, ruleMap[ruleName]); config[qualified] = 'error'; From 90df530869364aafcda48f633a17cc1a6611d729 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 22:14:16 -0400 Subject: [PATCH 27/32] codemods: scoped postprocess + registry merge (M4) 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 --- .../codemods/src/adapters/emotion/detect.js | 102 +++++++++- .../src/adapters/emotion/transform.js | 184 ++++++++++++++---- 2 files changed, 235 insertions(+), 51 deletions(-) diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js index 7a8a8e791..1b804bb00 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js @@ -118,20 +118,102 @@ export function detectSites( }); } - if ( - root - .find(j.ImportDeclaration) - .some( - (path: $FlowFixMe) => - String(path.node.source.value) === '@stylexjs/stylex', - ) - ) { + return { sites, blockers }; +} + +export type ExistingRegistry = { + +objectNode: $FlowFixMe, // the ObjectExpression passed to stylex.create + +varName: string, // the `const = stylex.create(...)` binding + +keys: $ReadOnlySet, // existing style-name keys +}; + +export type RegistryDetection = { + +registry: ExistingRegistry | null, + +stylexImported: boolean, + +blockers: Array, +}; + +/** + * Finds a pre-existing `import * as stylex` + `const X = stylex.create({...})` + * so new styles can be MERGED into it (M4) rather than the file being refused. + * Anything non-standard (stylex imported by a name other than a namespace, + * ≥2 creates, a non-object/non-const create) is a blocker. + */ +export function detectExistingRegistry( + j: $FlowFixMe, + root: $FlowFixMe, +): RegistryDetection { + const blockers: Array = []; + + const stylexImport = root + .find(j.ImportDeclaration) + .filter( + (path: $FlowFixMe) => + String(path.node.source.value) === '@stylexjs/stylex', + ); + if (stylexImport.size() === 0) { + return { registry: null, stylexImported: false, blockers }; + } + + const specifiers = stylexImport.paths()[0].node.specifiers ?? []; + const namespace = specifiers.find( + (s: $FlowFixMe) => s.type === 'ImportNamespaceSpecifier', + ); + if (specifiers.length !== 1 || namespace == null) { blockers.push( - 'file already imports @stylexjs/stylex (registry merge lands in M4)', + 'file imports @stylexjs/stylex in a non-namespace form (merge needs `import * as stylex`)', ); + return { registry: null, stylexImported: true, blockers }; } + const stylexLocal = namespace.local.name; - return { sites, blockers }; + const registries: Array = []; + root + .find(j.CallExpression) + .filter( + (path: $FlowFixMe) => + path.node.callee.type === 'MemberExpression' && + path.node.callee.object.type === 'Identifier' && + path.node.callee.object.name === stylexLocal && + path.node.callee.property.name === 'create', + ) + .forEach((path: $FlowFixMe) => { + const declarator = path.parent.node; + const arg = path.node.arguments[0]; + if ( + declarator.type !== 'VariableDeclarator' || + declarator.id.type !== 'Identifier' || + arg?.type !== 'ObjectExpression' + ) { + blockers.push( + 'pre-existing stylex.create is not a simple const object', + ); + return; + } + const keys = new Set(); + for (const prop of arg.properties) { + if (prop.key?.type === 'Identifier') { + keys.add(prop.key.name); + } else if ( + prop.key?.type === 'Literal' || + prop.key?.type === 'StringLiteral' + ) { + keys.add(String(prop.key.value)); + } + } + registries.push({ objectNode: arg, varName: declarator.id.name, keys }); + }); + + if (registries.length > 1) { + blockers.push('file has ≥2 stylex.create registries (merge targets one)'); + return { registry: null, stylexImported: true, blockers }; + } + + return { + registry: registries[0] ?? null, + stylexImported: true, + blockers, + }; } export type KeyframesSite = { diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js index ef3c3a3b8..7ce2af8e1 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -26,6 +26,7 @@ import type { } from '../../core/emit'; import { normalizeFileIR } from '../../core/normalize'; import { postprocess } from '../../core/postprocess'; +import { lintGate } from '../../core/gates/lint'; import { checkRule } from '../../core/referee'; import { parseSource, @@ -39,7 +40,7 @@ import { removeCssImport, insertStylexImport, } from './imports'; -import { detectSites, detectKeyframes } from './detect'; +import { detectSites, detectKeyframes, detectExistingRegistry } from './detect'; import { readSite, readKeyframes } from './read'; import type { PlainStyleObject } from './read'; import { rewriteSite } from './rewriteSites'; @@ -93,10 +94,13 @@ export function transformEmotionFile( ); const detection = detectSites(j, root, wiring.cssLocalName); + // A pre-existing `stylex.create` becomes a merge target rather than a refusal. + const registryDetection = detectExistingRegistry(j, root); const blockers = [ ...wiring.blockers, ...detection.blockers, ...kfDetection.blockers, + ...registryDetection.blockers, ]; const reads = detection.sites.map((s) => readSite(s, kfDetection.names)); for (const read of reads) { @@ -143,6 +147,7 @@ export function transformEmotionFile( if (conflicts.length > 0) { return { status: 'skipped', reasons: conflicts }; } + const existing = registryDetection.registry; const { rules, keyframes, bindings } = emitFileIR( { rules: refereed.map((r) => { @@ -153,31 +158,62 @@ export function transformEmotionFile( }), keyframes: fileIR.keyframes, }, - { hoverGuard: options?.hoverGuard ?? true }, + { + hoverGuard: options?.hoverGuard ?? true, + reservedKeys: existing?.keys, + }, ); + // L10 Scoped postprocess: run StyleX's own eslint autofixes on ONLY the + // emitted stylex (as a standalone snippet), so a user's pre-existing stylex + // is never linted or reordered. The fixed objects are spliced back below. + const stylesLocalName = + existing != null ? existing.varName : pickStylesName(j, root); + const fixed = scopedFix(j, rules, keyframes, stylesLocalName, filename); + if (fixed.residualErrors.length > 0) { + return { status: 'skipped', reasons: fixed.residualErrors }; + } + // Core -> adapter: place the StyleX back into the file's idiom. - const stylesLocalName = pickStylesName(j, root); detection.sites.forEach((site, i) => { rewriteSite(j, site, stylesLocalName, bindings[i]); }); - rewriteKeyframes(j, kfDetection.sites, keyframes); - if (rules.length > 0) { - insertRegistry(j, root, detection.sites[0], stylesLocalName, rules); + rewriteKeyframes(j, kfDetection.sites, fixed.keyframesByName); + if (rules.length > 0 && fixed.createObject != null) { + if (existing != null) { + // Merge: append our (already-fixed) style entries to the user's + // registry. Top-level style names need no sort-keys ordering, and the + // user's own entries are left exactly as they were. + existing.objectNode.properties.push(...fixed.createObject.properties); + } else { + insertRegistry( + j, + root, + detection.sites[0], + stylesLocalName, + fixed.createObject, + ); + } + } + if (existing == null) { + insertStylexImport(j, root); } - insertStylexImport(j, root); removeCssImport(j, root); removePragma(j, root); - // L10 Postprocess: run StyleX's own eslint autofixes so key ordering and - // shorthands match its canonical form. Unfixable residual errors mean the - // output is not clean at error → refuse the whole file. - const { code, residualErrors } = postprocess( - printSource({ j, root }), - filename, - ); - if (residualErrors.length > 0) { - return { status: 'skipped', reasons: residualErrors }; + const code = printSource({ j, root }); + // Final safety VERIFY (not fix) over the whole file. Our emitted code was + // already fixed in isolation; this catches a merge into a user registry + // whose own pre-existing code is lint-dirty — we refuse rather than + // silently reorder it (that is the whole point of the scoped fix). + const finalLint = lintGate(code, { filename }); + if (!finalLint.ok) { + return { + status: 'skipped', + reasons: finalLint.messages.map( + (m) => `${m.ruleId ?? 'error'}: ${m.message} (line ${m.line})`, + ), + }; } return { @@ -199,33 +235,108 @@ export function transformEmotionFile( }; } +const STYLEX_IMPORT = "import * as stylex from '@stylexjs/stylex';"; + /** - * Rewrites each `keyframes({...})` call in place to - * `stylex.keyframes({...})`, replacing the frames object with the emitted - * (normalized) one. Emitted keyframes are matched to detected sites by the - * bound variable name. + * Scoped postprocess: build a standalone module of just the emitted stylex + * (keyframes + create + usage stubs), run StyleX's eslint autofixes on it, + * and extract the FIXED object expressions. The user's file is never linted, + * so their pre-existing stylex code cannot be reordered. + */ +function scopedFix( + j: $FlowFixMe, + rules: $ReadOnlyArray, + keyframes: $ReadOnlyArray, + stylesLocalName: string, + filename: string, +): { + createObject: $FlowFixMe | null, + keyframesByName: Map, + residualErrors: $ReadOnlyArray, +} { + const lines: Array = [STYLEX_IMPORT]; + for (const kf of keyframes) { + const framesObject: { [string]: EmittedStyle } = {}; + for (const frame of kf.frames) { + framesObject[frame.selector] = frame.style; + } + lines.push( + `const ${kf.name} = stylex.keyframes(` + + `${printExpr(j, styleToObjectAst(j, framesObject))});`, + ); + } + if (rules.length > 0) { + const createStyle: { [string]: EmittedStyle } = {}; + for (const rule of rules) { + createStyle[rule.key] = rule.style; + } + lines.push( + `const ${stylesLocalName} = stylex.create(` + + `${printExpr(j, styleToObjectAst(j, createStyle))});`, + ); + // Usage stubs so no-unused stays quiet (real usage is in the JSX). + for (const rule of rules) { + lines.push(`stylex.props(${stylesLocalName}.${rule.key});`); + } + } + + const { code, residualErrors } = postprocess(lines.join('\n'), filename, { + excludeRules: ['no-unused'], + }); + + const parsed = j(code); + let createObject: $FlowFixMe | null = null; + const keyframesByName: Map = new Map(); + parsed.find(j.CallExpression).forEach((path: $FlowFixMe) => { + const callee = path.node.callee; + if ( + callee.type !== 'MemberExpression' || + callee.object.type !== 'Identifier' || + callee.object.name !== 'stylex' + ) { + return; + } + if (callee.property.name === 'create') { + createObject = path.node.arguments[0]; + } else if (callee.property.name === 'keyframes') { + const declarator = path.parent.node; + if ( + declarator.type === 'VariableDeclarator' && + declarator.id.type === 'Identifier' + ) { + keyframesByName.set(declarator.id.name, path.node.arguments[0]); + } + } + }); + + return { createObject, keyframesByName, residualErrors }; +} + +/** Prints a single expression node to source (via a throwaway wrapper). */ +function printExpr(j: $FlowFixMe, node: $FlowFixMe): string { + return j(j.expressionStatement(node)) + .toSource({ quote: 'single' }) + .replace(/;\s*$/, ''); +} + +/** + * Rewrites each `keyframes({...})` call in place to `stylex.keyframes()`, matched to detected sites by the bound variable name. */ function rewriteKeyframes( j: $FlowFixMe, sites: $ReadOnlyArray<{ +callPath: $FlowFixMe, +varName: string, ... }>, - emitted: $ReadOnlyArray, + fixedByName: Map, ): void { - const byName: Map = new Map( - emitted.map((kf) => [kf.name, kf]), - ); for (const site of sites) { - const kf = byName.get(site.varName); - if (kf == null) { + const framesObject = fixedByName.get(site.varName); + if (framesObject == null) { continue; } - const framesObject: { [string]: EmittedStyle } = {}; - for (const frame of kf.frames) { - framesObject[frame.selector] = frame.style; - } j(site.callPath).replaceWith( j.callExpression( j.memberExpression(j.identifier('stylex'), j.identifier('keyframes')), - [styleToObjectAst(j, framesObject)], + [framesObject], ), ); } @@ -251,17 +362,8 @@ function insertRegistry( root: $FlowFixMe, firstSite: $FlowFixMe, stylesLocalName: string, - rules: $ReadOnlyArray, + createObject: $FlowFixMe, ): void { - const createObject = j.objectExpression( - rules.map((rule) => - j.property( - 'init', - j.identifier(rule.key), - styleToObjectAst(j, rule.style), - ), - ), - ); const declaration = j.variableDeclaration('const', [ j.variableDeclarator( j.identifier(stylesLocalName), From 2a3b52f4d32b5fdeee254ca73832e73ccb1262a0 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Tue, 21 Jul 2026 22:41:12 -0400 Subject: [PATCH 28/32] codemods: M4 tests + merge fixture 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 --- .../merge-existing-registry/expected.js | 7 +- .../__tests__/emotion-fixtures-test.js | 13 ++- .../__tests__/scoped-postprocess-test.js | 82 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 packages/@stylexjs/codemods/__tests__/scoped-postprocess-test.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js index 56dc390c8..196ae8402 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/merge-existing-registry/expected.js @@ -1,4 +1,3 @@ -/** @jsxImportSource @emotion/react */ import * as React from 'react'; import * as stylex from '@stylexjs/stylex'; @@ -6,12 +5,16 @@ const styles = stylex.create({ card: { padding: '8px', }, + + mixed: { + color: 'gray', + }, }); export default function Mixed() { return (
- Mixed + Mixed
); } diff --git a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js index f0f8e07bf..224396648 100644 --- a/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js +++ b/packages/@stylexjs/codemods/__tests__/emotion-fixtures-test.js @@ -116,10 +116,21 @@ describe.each(fixtures.map((f) => [f.name, f]))( } return; } - // Before: Emotion's own serializer over each converted object. + // Before: any pre-existing StyleX in the INPUT (unchanged by a merge, + // so it appears on both sides) + Emotion's serializer over each + // converted object. // (Fixture-design constraint: sites must not restate the same // property+conditions with different values, or the union is lossy.) const before: { [string]: $FlowFixMe } = {}; + const inputCompiled = compileGate(fixture.input, { + filename: fixture.inputPath, + }); + if (inputCompiled.ok) { + const preExisting = netCssFromStylexMetadata(inputCompiled.metadata); + for (const coordinate of Object.keys(preExisting)) { + before[coordinate] = preExisting[coordinate]; + } + } for (const site of result.sites) { const net = netCssFromSerializedCss( serializeStyles([site.cssObject]).styles, diff --git a/packages/@stylexjs/codemods/__tests__/scoped-postprocess-test.js b/packages/@stylexjs/codemods/__tests__/scoped-postprocess-test.js new file mode 100644 index 000000000..602285a39 --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/scoped-postprocess-test.js @@ -0,0 +1,82 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Proves the M4 scoping guarantee: the postprocess autofix runs only on the + * codemod's OWN emitted stylex (a standalone snippet), never on the user's + * pre-existing `stylex.create`. So a user's existing registry is either + * preserved exactly (when clean) or the file is refused (when the user's own + * code is lint-dirty) — never silently reordered. + */ + +import { transformEmotionFile } from '../src/adapters/emotion/transform'; + +const HEADER = + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n" + + "import * as stylex from '@stylexjs/stylex';\n"; + +test('merge appends to a pre-existing registry and leaves the user entry untouched', () => { + const input = + HEADER + + 'const styles = stylex.create({\n' + + " card: { padding: '8px' },\n" + + '});\n' + + 'export default function C() {\n' + + " return
x
;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('converted'); + if (result.status === 'converted') { + // The user's `card` entry survives verbatim; ours is appended (named `c` + // after the enclosing component). + expect(result.code).toMatch(/card:\s*{\s*padding: '8px'\s*}/); + expect(result.code).toMatch(/stylex\.props\(styles\.c\)/); + // No second registry / duplicate import. + expect(result.code.match(/stylex\.create/g)).toHaveLength(1); + expect(result.code.match(/@stylexjs\/stylex/g)).toHaveLength(1); + } +}); + +test('a user registry with lint-dirty ordering is REFUSED, never silently reordered', () => { + // The user's `card` object has unsorted keys (zIndex before color) — a + // file-wide autofix would reorder them. Our scoped fix does not touch it, + // so the final verify flags it and we refuse rather than rewrite user code. + const input = + HEADER + + 'const styles = stylex.create({\n' + + " card: { zIndex: 1, color: 'red' },\n" + + '});\n' + + 'export default function C() {\n' + + " return
x
;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('skipped'); + if (result.status === 'skipped') { + expect(result.reasons.join('\n')).toMatch(/sort-keys/); + } +}); + +test('keys collide-safely with the pre-existing registry (numeric suffix)', () => { + // Our converted
would want the name `mixed`; the user already has it. + const input = + HEADER + + 'const styles = stylex.create({\n' + + " mixed: { padding: '8px' },\n" + + '});\n' + + 'export default function Mixed() {\n' + + " return
x
;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('converted'); + if (result.status === 'converted') { + expect(result.code).toMatch(/mixed2:/); // suffixed to avoid collision + expect(result.code).toMatch(/stylex\.props\(styles\.mixed2\)/); + } +}); From 660e9aa5a94e60bb8f8a2eb843388a26bc98c7de Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Wed, 22 Jul 2026 02:13:23 -0400 Subject: [PATCH 29/32] codemods: todos.js (flag reasons + marker) + jsxComment rewriter helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../expected.js | 0 .../input.js | 0 .../expected.js | 0 .../input.js | 0 .../expected.js | 0 .../input.js | 0 .../expected.js | 0 .../input.js | 0 .../expected.js | 0 .../input.js | 0 .../expected.js | 0 .../input.js | 0 .../@stylexjs/codemods/src/core/rewriter.js | 15 ++++++ packages/@stylexjs/codemods/src/core/todos.js | 53 +++++++++++++++++++ 14 files changed, 68 insertions(+) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-component-css => flag-component-css}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-component-css => flag-component-css}/input.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-descendant-selector => flag-descendant-selector}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-descendant-selector => flag-descendant-selector}/input.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-referee-conflict => flag-referee-conflict}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-referee-conflict => flag-referee-conflict}/input.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-shorthand-conflict => flag-shorthand-conflict}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-shorthand-conflict => flag-shorthand-conflict}/input.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-sibling-media => flag-sibling-media}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-sibling-media => flag-sibling-media}/input.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-template-literal => flag-template-literal}/expected.js (100%) rename packages/@stylexjs/codemods/__fixtures__/emotion/{skip-template-literal => flag-template-literal}/input.js (100%) create mode 100644 packages/@stylexjs/codemods/src/core/todos.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-component-css/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-descendant-selector/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-referee-conflict/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-shorthand-conflict/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-sibling-media/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/input.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/expected.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/input.js similarity index 100% rename from packages/@stylexjs/codemods/__fixtures__/emotion/skip-template-literal/input.js rename to packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/input.js diff --git a/packages/@stylexjs/codemods/src/core/rewriter.js b/packages/@stylexjs/codemods/src/core/rewriter.js index 8e6d60f8e..ef4179939 100644 --- a/packages/@stylexjs/codemods/src/core/rewriter.js +++ b/packages/@stylexjs/codemods/src/core/rewriter.js @@ -50,6 +50,21 @@ export function printSource(rewriter: Rewriter): string { return rewriter.root.toSource({ quote: 'single' }); } +/** + * Builds a JSX comment container node — the braced comment form that is valid + * as a JSX child, unlike a bare block comment which would render as visible + * text. Parses a real one so recast reprints it correctly. + */ +export function jsxComment(j: $FlowFixMe, text: string): $FlowFixMe { + let container = null; + j(`{/*${text}*/}`) + .find(j.JSXExpressionContainer) + .forEach((path: $FlowFixMe) => { + container = path.node; + }); + return container; +} + /** * Renders emitted style data (plain values, fallback arrays, or nested * condition objects) as an ObjectExpression — the bridge that lets diff --git a/packages/@stylexjs/codemods/src/core/todos.js b/packages/@stylexjs/codemods/src/core/todos.js new file mode 100644 index 000000000..569376617 --- /dev/null +++ b/packages/@stylexjs/codemods/src/core/todos.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * L? — Bail-loudly flagging. When a single style site is unconvertible, the + * codemod no longer skips the whole file: it converts what it safely can and + * leaves a precise `// TODO(stylex-migration): …` marker on the site a human + * must handle. This module owns the marker text and the canonical reasons. + * + * The reason strings that flow from the detect/read/referee layers are + * already human-readable; `REASONS` documents the recurring categories so + * they stay consistent, but any descriptive string is accepted. + */ + +export const TODO_PREFIX: string = 'TODO(stylex-migration)'; + +/** Canonical reasons for the recurring unconvertible categories. */ +export const REASONS: { + +templateLiteral: string, + +dynamicValue: string, + +componentElement: string, + +propConflict: string, + +refereeConflict: string, + +siblingMedia: string, + +outOfElement: string, +} = { + templateLiteral: 'template-literal styles are not statically analyzable yet', + dynamicValue: 'dynamic value — needs a StyleX function-form style (v1.1)', + componentElement: + 'css on a component — className forwarding is not provable here', + propConflict: 'css mixed with className/style/spread on the same element', + refereeConflict: + 'Emotion source order and StyleX priority disagree on the winning value', + siblingMedia: 'multiple sibling media queries whose order is significant', + outOfElement: 'selector reaches outside the element', +}; + +/** The block-comment body for a flagged site (note the surrounding spaces). */ +export function formatTodo(reason: string): string { + return ` ${TODO_PREFIX}: ${reason} `; +} + +/** Whether a comment body is one of our markers (re-run guard: an already + * flagged site is left alone on a second pass). */ +export function isTodoMarker(commentValue: string): boolean { + return commentValue.includes(TODO_PREFIX); +} From d56ba401f5c382d5af791760b19f54d7c6a4ff52 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Wed, 22 Jul 2026 02:13:28 -0400 Subject: [PATCH 30/32] =?UTF-8?q?codemods:=20per-site=20TODO=20flagging=20?= =?UTF-8?q?(M5)=20=E2=80=94=20bail=20loudly=20in=20place,=20not=20whole-fi?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../codemods/src/adapters/emotion/detect.js | 68 +++-- .../codemods/src/adapters/emotion/imports.js | 57 ++++- .../src/adapters/emotion/transform.js | 238 ++++++++++++------ 3 files changed, 247 insertions(+), 116 deletions(-) diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js index 1b804bb00..ed851827a 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/detect.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/detect.js @@ -19,17 +19,32 @@ * on host (lowercase) elements with no `className`/`style`/spread props. */ -export type StyleSite = { +import { REASONS } from '../../core/todos'; + +export type ConvertibleSite = { + +kind: 'convertible', +attrPath: $FlowFixMe, // JSXAttribute path +objectNode: $FlowFixMe, // the style ObjectExpression +tagName: string, }; +export type FlaggedSite = { + +kind: 'flagged', + +attrPath: $FlowFixMe, + +tagName: string, + +reason: string, +}; +export type StyleSite = ConvertibleSite | FlaggedSite; export type Detection = { +sites: Array, +blockers: Array, }; +/** + * Classifies every `css` prop as convertible (a static object) or flagged + * (with a reason). M5: a flagged site no longer skips the file — it gets a + * `// TODO(stylex-migration): …` marker while the rest of the file converts. + */ export function detectSites( j: $FlowFixMe, root: $FlowFixMe, @@ -37,17 +52,18 @@ export function detectSites( ): Detection { const sites: Array = []; const blockers: Array = []; - const siteCallees = new Set<$FlowFixMe>(); root .find(j.JSXAttribute, { name: { name: 'css' } }) .forEach((path: $FlowFixMe) => { const opening = path.parent.node; const nameNode = opening.name; - if (nameNode.type !== 'JSXIdentifier' || !/^[a-z]/.test(nameNode.name)) { - blockers.push( - 'css prop on a component element (className forwarding is not provable)', - ); + const tagName = String(nameNode.name ?? '?'); + const flag = (reason: string) => + sites.push({ kind: 'flagged', attrPath: path, tagName, reason }); + + if (nameNode.type !== 'JSXIdentifier' || !/^[a-z]/.test(tagName)) { + flag(REASONS.componentElement); return; } const conflicting = (opening.attributes ?? []).find( @@ -57,22 +73,21 @@ export function detectSites( attr.name?.name === 'style', ); if (conflicting != null) { - blockers.push( - `<${nameNode.name}> mixes css with className/style/spread props`, - ); + flag(REASONS.propConflict); return; } const container = path.node.value; if (container?.type !== 'JSXExpressionContainer') { - blockers.push(`css prop on <${nameNode.name}> is not an expression`); + flag('css prop is not an expression'); return; } const expression = container.expression; if (expression.type === 'ObjectExpression') { sites.push({ + kind: 'convertible', attrPath: path, objectNode: expression, - tagName: nameNode.name, + tagName, }); return; } @@ -84,40 +99,21 @@ export function detectSites( expression.arguments.length === 1 && expression.arguments[0].type === 'ObjectExpression' ) { - siteCallees.add(expression.callee); sites.push({ + kind: 'convertible', attrPath: path, objectNode: expression.arguments[0], - tagName: nameNode.name, + tagName, }); return; } - blockers.push( - `css prop value form on <${nameNode.name}> is not convertible yet ` + - '(template literals, arrays, references and dynamic styles land in later milestones)', + flag( + expression.type === 'TaggedTemplateExpression' + ? REASONS.templateLiteral + : REASONS.dynamicValue, ); }); - if (cssLocalName != null) { - root - .find(j.Identifier, { name: cssLocalName }) - .forEach((path: $FlowFixMe) => { - const parentNode = path.parent.node; - if ( - parentNode.type === 'ImportSpecifier' || - siteCallees.has(path.node) || - (parentNode.type === 'Property' && parentNode.key === path.node) || - parentNode.type === 'JSXAttribute' - ) { - return; - } - blockers.push( - `'${cssLocalName}' is used outside a convertible css prop ` + - '(tagged templates / shared style variables land in later milestones)', - ); - }); - } - return { sites, blockers }; } diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/imports.js b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js index 27bf67ac3..c86ecdecd 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/imports.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/imports.js @@ -89,8 +89,14 @@ function findPragmaText(j: $FlowFixMe, root: $FlowFixMe): string | null { return null; } -/** Removes the `@jsxImportSource @emotion/react` pragma comment. */ +/** + * Removes the `@jsxImportSource @emotion/react` pragma — but only when no + * `css` prop remains (a flagged, still-Emotion site needs the pragma). + */ export function removePragma(j: $FlowFixMe, root: $FlowFixMe): void { + if (root.find(j.JSXAttribute, { name: { name: 'css' } }).size() > 0) { + return; + } for (const slot of allCommentSlots(j, root)) { if (slot.comments != null) { slot.comments = slot.comments.filter( @@ -100,11 +106,51 @@ export function removePragma(j: $FlowFixMe, root: $FlowFixMe): void { } } +/** + * Whether an identifier name is still referenced AS A VALUE — not as its own + * import specifier, a member-access property (`stylex.keyframes`), or an + * object key. Without those exclusions the newly-emitted `stylex.keyframes` + * property would be mistaken for a use of the Emotion `keyframes` import. + */ +function isStillReferenced( + j: $FlowFixMe, + root: $FlowFixMe, + name: string, +): boolean { + return ( + root + .find(j.Identifier, { name }) + .filter((path: $FlowFixMe) => { + const parent = path.parent.node; + if (parent.type === 'ImportSpecifier') { + return false; + } + if ( + parent.type === 'MemberExpression' && + parent.property === path.node && + !parent.computed + ) { + return false; + } + if ( + (parent.type === 'Property' || parent.type === 'ObjectProperty') && + parent.key === path.node && + !parent.computed + ) { + return false; + } + return true; + }) + .size() > 0 + ); +} + /** * Removes the converted specifiers (`css`, `keyframes`) from the - * `@emotion/react` import (the whole declaration when nothing else remains), - * transplanting any non-pragma comments onto the next statement so file - * headers survive. + * `@emotion/react` import — but only those whose local name is no longer + * referenced (a flagged tagged-template still using `css` keeps it). Drops the + * whole declaration when nothing remains, transplanting any non-pragma + * comments onto the next statement so file headers survive. */ export function removeCssImport(j: $FlowFixMe, root: $FlowFixMe): void { root.find(j.ImportDeclaration).forEach((path: $FlowFixMe) => { @@ -115,7 +161,8 @@ export function removeCssImport(j: $FlowFixMe, root: $FlowFixMe): void { (specifier: $FlowFixMe) => !( specifier.type === 'ImportSpecifier' && - CONVERTIBLE_IMPORTS.has(specifier.imported.name) + CONVERTIBLE_IMPORTS.has(specifier.imported.name) && + !isStillReferenced(j, root, specifier.local.name) ), ); if (remaining.length > 0) { diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js index 7ce2af8e1..47ddf5e70 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/transform.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/transform.js @@ -11,10 +11,12 @@ * The Emotion -> StyleX pipeline for one file: detect -> read (adapter), * buildIR -> emit (core), rewrite -> registry -> imports (adapter). * - * M1 policy (user-ratified): whole-file-or-nothing. A file is rewritten - * only when every style site is convertible; any blocker returns - * `skipped` with the reasons and the source untouched. TODO-comment - * flagging machinery lands in M5. + * M5 policy (bail loudly, per site): every convertible css site is rewritten + * and each unconvertible one is left in place with a + * `// TODO(stylex-migration): …` marker — the file is no longer skipped + * wholesale. Genuinely file-level structural issues (a non-namespace stylex + * import, ≥2 registries, an unconvertible keyframes) still refuse the whole + * file with stated reasons. Nothing is ever silently dropped or guessed. */ import { buildFileIR } from '../../core/buildIR'; @@ -28,11 +30,13 @@ import { normalizeFileIR } from '../../core/normalize'; import { postprocess } from '../../core/postprocess'; import { lintGate } from '../../core/gates/lint'; import { checkRule } from '../../core/referee'; +import { formatTodo, isTodoMarker } from '../../core/todos'; import { parseSource, printSource, parserForFile, styleToObjectAst, + jsxComment, } from '../../core/rewriter'; import { analyzeEmotionWiring, @@ -53,6 +57,8 @@ export type TransformResult = +keyframes: $ReadOnlyArray<{ +framesObject: { +[selector: string]: { +[string]: mixed } }, }>, + /** Reasons for each site left in place with a TODO marker (M5). */ + +flags: $ReadOnlyArray, } | { +status: 'skipped', +reasons: $ReadOnlyArray } | { +status: 'unchanged' }; @@ -94,79 +100,103 @@ export function transformEmotionFile( ); const detection = detectSites(j, root, wiring.cssLocalName); - // A pre-existing `stylex.create` becomes a merge target rather than a refusal. const registryDetection = detectExistingRegistry(j, root); - const blockers = [ + + // Whole-file refusals: genuinely file-level structural issues (not a single + // site). Keyframes stay whole-file for now — per-site keyframe flagging is a + // later slice. + const wholeFileBlockers = [ ...wiring.blockers, ...detection.blockers, ...kfDetection.blockers, ...registryDetection.blockers, + ...kfReads.map((r) => (r.ok ? null : r.blocker)).filter(Boolean), ]; - const reads = detection.sites.map((s) => readSite(s, kfDetection.names)); - for (const read of reads) { - if (!read.ok) { - blockers.push(read.blocker); - } - } - for (const kf of kfReads) { - if (!kf.ok) { - blockers.push(kf.blocker); - } - } - if (blockers.length > 0) { - return { status: 'skipped', reasons: blockers }; - } - if (detection.sites.length === 0 && kfDetection.sites.length === 0) { - return { status: 'unchanged' }; + if (wholeFileBlockers.length > 0) { + return { status: 'skipped', reasons: wholeFileBlockers }; } - // Adapter -> core: declarations in, create data + binding map out. - const groups = reads.map((read) => { - if (!read.ok) { - throw new Error('unreachable: blockers were checked above'); + // --- Per-site classification: each css site either converts or is flagged + // with a `// TODO(stylex-migration): …` marker (bail loudly, in place). --- + const flags: Array<{ +attrPath: $FlowFixMe, +reason: string }> = []; + const candidates: Array<{ +site: $FlowFixMe, +read: $FlowFixMe }> = []; + for (const site of detection.sites) { + if (site.kind === 'flagged') { + flags.push({ attrPath: site.attrPath, reason: site.reason }); + continue; } - return { nameHint: read.nameHint, declarations: read.declarations }; - }); - const keyframeRules = kfReads.map((kf) => { - if (!kf.ok) { - throw new Error('unreachable: blockers were checked above'); + if (siteAlreadyFlagged(j, site.attrPath)) { + continue; // re-run guard: leave a previously-flagged site alone } - return kf.rule; - }); - // L6 Normalize runs before the referee so every downstream layer sees one - // vocabulary (physical→logical is a sanctioned RTL change). + const read = readSite(site, kfDetection.names); + if (read.ok) { + candidates.push({ site, read }); + } else { + flags.push({ attrPath: site.attrPath, reason: read.blocker }); + } + } + + // Adapter -> core for the convertible candidates. const fileIR = normalizeFileIR( - { rules: buildFileIR(groups).rules, keyframes: keyframeRules }, + { + rules: buildFileIR( + candidates.map((c) => ({ + nameHint: c.read.nameHint, + declarations: c.read.declarations, + })), + ).rules, + keyframes: kfReads.map((r) => { + if (!r.ok) { + throw new Error('unreachable: keyframes blockers checked above'); + } + return r.rule; + }), + }, { logicalProperties: options?.logicalProperties ?? true }, ); - // L5 Referee: convert only when Emotion's cascade and StyleX's priority - // agree on every simultaneously-active condition; otherwise refuse. - const refereed = fileIR.rules.map(checkRule); - const conflicts = refereed.flatMap((r) => (r.ok ? [] : r.conflicts)); - if (conflicts.length > 0) { - return { status: 'skipped', reasons: conflicts }; + // L5 Referee, per rule: a conflicting site is flagged, not fatal to the file. + const convertRules: Array<{ +rule: $FlowFixMe, +candidateIndex: number }> = + []; + fileIR.rules.forEach((rule, i) => { + const checked = checkRule(rule); + if (checked.ok) { + convertRules.push({ rule: checked.rule, candidateIndex: i }); + } else { + flags.push({ + attrPath: candidates[i].site.attrPath, + reason: checked.conflicts[0], + }); + } + }); + + if (convertRules.length === 0 && kfDetection.sites.length === 0) { + if (flags.length === 0) { + return { status: 'unchanged' }; + } + // Nothing convertible, but sites to flag: inject TODOs and keep Emotion. + for (const flag of flags) { + injectTodo(j, flag.attrPath, flag.reason); + } + return { + status: 'converted', + code: printSource({ j, root }), + sites: [], + keyframes: [], + flags: flags.map((f) => f.reason), + }; } + const existing = registryDetection.registry; const { rules, keyframes, bindings } = emitFileIR( { - rules: refereed.map((r) => { - if (!r.ok) { - throw new Error('unreachable: conflicts were checked above'); - } - return r.rule; - }), + rules: convertRules.map((c) => c.rule), keyframes: fileIR.keyframes, }, - { - hoverGuard: options?.hoverGuard ?? true, - reservedKeys: existing?.keys, - }, + { hoverGuard: options?.hoverGuard ?? true, reservedKeys: existing?.keys }, ); - // L10 Scoped postprocess: run StyleX's own eslint autofixes on ONLY the - // emitted stylex (as a standalone snippet), so a user's pre-existing stylex - // is never linted or reordered. The fixed objects are spliced back below. + // L10 Scoped postprocess (see scopedFix): fixes ONLY our emitted stylex. const stylesLocalName = existing != null ? existing.varName : pickStylesName(j, root); const fixed = scopedFix(j, rules, keyframes, stylesLocalName, filename); @@ -174,38 +204,44 @@ export function transformEmotionFile( return { status: 'skipped', reasons: fixed.residualErrors }; } - // Core -> adapter: place the StyleX back into the file's idiom. - detection.sites.forEach((site, i) => { - rewriteSite(j, site, stylesLocalName, bindings[i]); + // Rewrite converted sites; flag the rest in place. + convertRules.forEach((c, k) => { + rewriteSite( + j, + candidates[c.candidateIndex].site, + stylesLocalName, + bindings[k], + ); }); rewriteKeyframes(j, kfDetection.sites, fixed.keyframesByName); + for (const flag of flags) { + injectTodo(j, flag.attrPath, flag.reason); + } + if (rules.length > 0 && fixed.createObject != null) { if (existing != null) { - // Merge: append our (already-fixed) style entries to the user's - // registry. Top-level style names need no sort-keys ordering, and the - // user's own entries are left exactly as they were. existing.objectNode.properties.push(...fixed.createObject.properties); } else { insertRegistry( j, root, - detection.sites[0], + candidates[convertRules[0].candidateIndex].site, stylesLocalName, fixed.createObject, ); } } - if (existing == null) { + if (existing == null && (rules.length > 0 || keyframes.length > 0)) { insertStylexImport(j, root); } + // Remove Emotion wiring only where it is no longer referenced: flagged css + // props keep the pragma; a still-used `css`/`keyframes` keeps its import. removeCssImport(j, root); removePragma(j, root); const code = printSource({ j, root }); - // Final safety VERIFY (not fix) over the whole file. Our emitted code was - // already fixed in isolation; this catches a merge into a user registry - // whose own pre-existing code is lint-dirty — we refuse rather than - // silently reorder it (that is the whole point of the scoped fix). + // Final safety VERIFY (not fix) over the whole file — catches a merge into a + // user registry whose own code is lint-dirty; we refuse rather than rewrite. const finalLint = lintGate(code, { filename }); if (!finalLint.ok) { return { @@ -219,22 +255,74 @@ export function transformEmotionFile( return { status: 'converted', code, - sites: detection.sites.map((site, i) => { - const read = reads[i]; - if (!read.ok) { - throw new Error('unreachable: blockers were checked above'); - } - return { key: bindings[i], cssObject: read.cssObject }; - }), + sites: convertRules.map((c, k) => ({ + key: bindings[k], + cssObject: candidates[c.candidateIndex].read.cssObject, + })), keyframes: kfReads.map((kf) => { if (!kf.ok) { - throw new Error('unreachable: blockers were checked above'); + throw new Error('unreachable: keyframes blockers checked above'); } return { framesObject: kf.framesObject }; }), + flags: flags.map((f) => f.reason), }; } +/** Whether a css site was already flagged on a previous run (re-run guard): + * a TODO comment sibling immediately before its element, or leading on it. */ +function siteAlreadyFlagged(j: $FlowFixMe, attrPath: $FlowFixMe): boolean { + const elementPath = attrPath.parent.parent; + const parent = elementPath.parent.node; + const children = Array.isArray(parent.children) ? parent.children : null; + const leading = (elementPath.node.comments ?? []).some((c: $FlowFixMe) => + isTodoMarker(c.value), + ); + if (leading) { + return true; + } + if (children == null) { + return false; + } + const idx = children.indexOf(elementPath.node); + for (let i = idx - 1; i >= 0; i--) { + const sibling = children[i]; + if (sibling.type === 'JSXText' && String(sibling.value).trim() === '') { + continue; + } + return ( + sibling.type === 'JSXExpressionContainer' && + sibling.expression?.type === 'JSXEmptyExpression' && + (sibling.expression.comments ?? []).some((c: $FlowFixMe) => + isTodoMarker(c.value), + ) + ); + } + return false; +} + +/** Injects a `TODO(stylex-migration): reason` marker at a flagged css site: a + * braced JSX comment sibling when the element is a JSX child, else a leading + * block comment on the element (both valid, unlike a bare comment as a child). */ +function injectTodo(j: $FlowFixMe, attrPath: $FlowFixMe, reason: string): void { + const elementPath = attrPath.parent.parent; + const element = elementPath.node; + const parent = elementPath.parent.node; + const text = formatTodo(reason); + if ( + (parent.type === 'JSXElement' || parent.type === 'JSXFragment') && + Array.isArray(parent.children) + ) { + const idx = parent.children.indexOf(element); + parent.children.splice(idx, 0, jsxComment(j, text), j.jsxText('\n')); + } else { + element.comments = [ + ...(element.comments ?? []), + { type: 'CommentBlock', value: text, leading: true, trailing: false }, + ]; + } +} + const STYLEX_IMPORT = "import * as stylex from '@stylexjs/stylex';"; /** From 5ded2bf055d094f26e88ccdf851f459e13070109 Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Wed, 22 Jul 2026 02:17:53 -0400 Subject: [PATCH 31/32] codemods: M5 fixtures + flagging tests 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 --- .../emotion/flag-component-css/expected.js | 5 +- .../flag-descendant-selector/expected.js | 1 + .../emotion/flag-mixed/expected.js | 39 +++++++++ .../__fixtures__/emotion/flag-mixed/input.js | 24 ++++++ .../emotion/flag-referee-conflict/expected.js | 1 + .../flag-shorthand-conflict/expected.js | 5 +- .../emotion/flag-sibling-media/expected.js | 1 + .../emotion/flag-template-literal/expected.js | 5 +- .../refuse-non-namespace-stylex/expected.js | 13 +++ .../refuse-non-namespace-stylex/input.js | 13 +++ .../codemods/__tests__/flagging-test.js | 81 +++++++++++++++++++ 11 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/input.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/expected.js create mode 100644 packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/input.js create mode 100644 packages/@stylexjs/codemods/__tests__/flagging-test.js diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js index 856de0164..0d5278d76 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js @@ -3,5 +3,8 @@ import * as React from 'react'; import { Button } from './Button'; export default function Toolbar() { - return ; + return ( + /* TODO(stylex-migration): css on a component — className forwarding is not provable here */ + + ); } diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js index 79bcb3666..a5031977f 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-descendant-selector/expected.js @@ -5,6 +5,7 @@ import * as React from 'react'; // 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 */
    li': { color: 'gray' } }}>
  • Item
diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/expected.js new file mode 100644 index 000000000..7544f4f0b --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/expected.js @@ -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 ( +
+ convert me + {/* TODO(stylex-migration): 'color': Emotion source-order and StyleX priority disagree on which conditional value wins when several are active (:focus vs :hover) */} + + + convert me too + +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/input.js new file mode 100644 index 000000000..7a9c57611 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-mixed/input.js @@ -0,0 +1,24 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; + +export default function Panel() { + return ( +
+ convert me + + + convert me too + +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js index c38b05faa..f41797cd5 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-referee-conflict/expected.js @@ -6,6 +6,7 @@ import * as React from 'react'; // so the whole file must be refused (not silently converted incorrectly). export default function Toggle() { return ( + /* TODO(stylex-migration): 'color': Emotion source-order and StyleX priority disagree on which conditional value wins when several are active (:focus vs :hover) */
; + return ( + /* TODO(stylex-migration): shorthand/longhand overlap ('margin' + 'marginTop') needs the M2 referee */ +
Box
+ ); } diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js index b53202b41..bebf49d4e 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-sibling-media/expected.js @@ -6,6 +6,7 @@ import * as React from 'react'; // upstream inconsistency is resolved. export default function Panel() { return ( + /* TODO(stylex-migration): 'width': ≥2 sibling at-rule (e.g. @media) conditions whose order is semantic to the StyleX compiler but which sort-keys would reorder (@media (min-width: 700px), @media (min-width: 500px)) */
Fancy
; + return ( + /* TODO(stylex-migration): dynamic value — needs a StyleX function-form style (v1.1) */ +
Fancy
+ ); } diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/expected.js new file mode 100644 index 000000000..59e035764 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/expected.js @@ -0,0 +1,13 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { create, props } from '@stylexjs/stylex'; + +const styles = create({ card: { padding: '8px' } }); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/input.js b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/input.js new file mode 100644 index 000000000..59e035764 --- /dev/null +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/refuse-non-namespace-stylex/input.js @@ -0,0 +1,13 @@ +/** @jsxImportSource @emotion/react */ +import * as React from 'react'; +import { create, props } from '@stylexjs/stylex'; + +const styles = create({ card: { padding: '8px' } }); + +export default function Mixed() { + return ( +
+ Mixed +
+ ); +} diff --git a/packages/@stylexjs/codemods/__tests__/flagging-test.js b/packages/@stylexjs/codemods/__tests__/flagging-test.js new file mode 100644 index 000000000..0d3ab0f0d --- /dev/null +++ b/packages/@stylexjs/codemods/__tests__/flagging-test.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * M5 per-site flagging: convert what is safe, leave a `// TODO` marker on the + * rest, and never silently drop or re-flag. + */ + +import { transformEmotionFile } from '../src/adapters/emotion/transform'; + +const HEADER = + '/** @jsxImportSource @emotion/react */\n' + + "import * as React from 'react';\n"; + +test('a flag marker is not duplicated on a second run (re-run guard)', () => { + const input = + HEADER + + 'export default function C() {\n' + + ' return (\n' + + '
\n' + + ' \n' + + '
\n' + + ' );\n' + + '}\n'; + const first = transformEmotionFile(input, 'in.js'); + expect(first.status).toBe('converted'); + if (first.status !== 'converted') { + return; + } + expect(first.flags.length).toBe(1); + const markerCount = (s: string) => + (s.match(/TODO\(stylex-migration\)/g) ?? []).length; + expect(markerCount(first.code)).toBe(1); + + // Running again on the already-flagged output must not add a second marker. + const second = transformEmotionFile(first.code, 'in.js'); + if (second.status === 'converted') { + expect(markerCount(second.code)).toBe(1); + } else { + // 'unchanged' is also acceptable (nothing left to do). + expect(second.status).toBe('unchanged'); + } +}); + +test('a whole-file structural issue still refuses (does not flag)', () => { + // Non-namespace stylex import can't be merged into — whole-file refusal. + const input = [ + '/** @jsxImportSource @emotion/react */', + "import * as React from 'react';", + "import { create } from '@stylexjs/stylex';", + "const s = create({ a: { color: 'red' } });", + 'export default function C() {', + " return {s ? 'x' : 'y'};", + '}', + '', + ].join('\n'); + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('skipped'); + if (result.status === 'skipped') { + expect(result.reasons.join('\n')).toMatch(/namespace/); + } +}); + +test('a fully-convertible file reports no flags', () => { + const input = + HEADER + + 'export default function C() {\n' + + " return x;\n" + + '}\n'; + const result = transformEmotionFile(input, 'in.js'); + expect(result.status).toBe('converted'); + if (result.status === 'converted') { + expect(result.flags).toEqual([]); + } +}); From 988af79ebf545543e766292d88a13f41503a8d9f Mon Sep 17 00:00:00 2001 From: ahmedsadid Date: Wed, 22 Jul 2026 11:31:46 -0400 Subject: [PATCH 32/32] codemods: make committed TODO reasons timeless (drop roadmap/version 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 --- .../emotion/flag-component-css/expected.js | 2 +- .../flag-shorthand-conflict/expected.js | 2 +- .../emotion/flag-template-literal/expected.js | 2 +- .../codemods/src/adapters/emotion/read.js | 7 ++---- packages/@stylexjs/codemods/src/core/todos.js | 24 +++++++++---------- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js index 0d5278d76..ef7a9bd48 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-component-css/expected.js @@ -4,7 +4,7 @@ import { Button } from './Button'; export default function Toolbar() { return ( - /* TODO(stylex-migration): css on a component — className forwarding is not provable here */ + /* TODO(stylex-migration): css on a component element */ ); } diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js index d97d2c0fa..d436386a5 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-shorthand-conflict/expected.js @@ -3,7 +3,7 @@ import * as React from 'react'; export default function Box() { return ( - /* TODO(stylex-migration): shorthand/longhand overlap ('margin' + 'marginTop') needs the M2 referee */ + /* TODO(stylex-migration): shorthand/longhand overlap ('margin' + 'marginTop') */
Box
); } diff --git a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js index 6f6243981..3c492394c 100644 --- a/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js +++ b/packages/@stylexjs/codemods/__fixtures__/emotion/flag-template-literal/expected.js @@ -8,7 +8,7 @@ const fancy = css` export default function Fancy() { return ( - /* TODO(stylex-migration): dynamic value — needs a StyleX function-form style (v1.1) */ + /* TODO(stylex-migration): dynamic value (props-driven) */
Fancy
); } diff --git a/packages/@stylexjs/codemods/src/adapters/emotion/read.js b/packages/@stylexjs/codemods/src/adapters/emotion/read.js index f2e69ac33..2f819cc93 100644 --- a/packages/@stylexjs/codemods/src/adapters/emotion/read.js +++ b/packages/@stylexjs/codemods/src/adapters/emotion/read.js @@ -242,10 +242,7 @@ export function readSite( const value = valueOf(valueNode); if (value == null) { - return ( - `value of '${key}' is not a static string/number/fallback-array ` + - '(dynamic values land in v1.1)' - ); + return `dynamic value (props-driven) on '${key}'`; } declarations.push({ property: key, value, conditions }); mirror[key] = plainOf(value); @@ -293,7 +290,7 @@ export function readSite( if (overlap != null) { return { ok: false, - blocker: `shorthand/longhand overlap (${overlap}) needs the M2 referee`, + blocker: `shorthand/longhand overlap (${overlap})`, }; } } diff --git a/packages/@stylexjs/codemods/src/core/todos.js b/packages/@stylexjs/codemods/src/core/todos.js index 569376617..e4227500e 100644 --- a/packages/@stylexjs/codemods/src/core/todos.js +++ b/packages/@stylexjs/codemods/src/core/todos.js @@ -20,25 +20,23 @@ export const TODO_PREFIX: string = 'TODO(stylex-migration)'; -/** Canonical reasons for the recurring unconvertible categories. */ +/** + * Canonical reasons for the recurring unconvertible categories. These land in + * comments committed to the user's source, so they are short, plain, and + * TIMELESS — no roadmap/version references (those go stale and read + * ambiguously). Anything shipped-later belongs in the dry-run report, not the + * committed comment. + */ export const REASONS: { +templateLiteral: string, +dynamicValue: string, +componentElement: string, +propConflict: string, - +refereeConflict: string, - +siblingMedia: string, - +outOfElement: string, } = { - templateLiteral: 'template-literal styles are not statically analyzable yet', - dynamicValue: 'dynamic value — needs a StyleX function-form style (v1.1)', - componentElement: - 'css on a component — className forwarding is not provable here', - propConflict: 'css mixed with className/style/spread on the same element', - refereeConflict: - 'Emotion source order and StyleX priority disagree on the winning value', - siblingMedia: 'multiple sibling media queries whose order is significant', - outOfElement: 'selector reaches outside the element', + templateLiteral: 'template-literal styles', + dynamicValue: 'dynamic value (props-driven)', + componentElement: 'css on a component element', + propConflict: 'css mixed with className/style/spread', }; /** The block-comment body for a flagged site (note the surrounding spaces). */