# bestax-migrate — codemods onto bestax-bulma

jscodeshift-based CLI (`pnpm dlx bestax-migrate <source> <paths…>`) that migrates existing
apps from other React Bulma libraries to `@allxsmith/bestax-bulma`. Multi-source by design:
each source registers in `src/sources/registry.ts`. The library sources are
`react-bulma-components` (v4 only), `rbx` (v2 only) and `bloomer` (0.6 only); `bulma-classes`
is the one with no library behind it, converting raw Bulma classes on plain JSX.

## Hard rules

- **No source library is EVER installed anywhere in this repo** (supply-chain policy) —
  not react-bulma-components, not rbx, not bloomer. Fixtures are read as _text_ (`__testfixtures__`,
  `fixtures/kitchen-sink`, `fixtures/rbx-kitchen-sink`, `fixtures/bloomer-kitchen-sink`); the migration _input_ is never
  typechecked or executed. Validation typechecks the migrated _output_ against the
  workspace `@allxsmith/bestax-bulma`. The kitchen-sink manifests are named
  `package.input.json`, not `package.json`: Dependabot's alert graph treats any file with
  that name as a real manifest whatever the lockfile says, and opened a security-update PR
  against the rbx fixture's deliberately-old `node-sass` (#615). The e2e renames its copy
  back inside `.e2e-tmp`, so the pass under test still sees a genuine `package.json`.
  `bulma-classes` has no library, so its input is plain JSX, and its tests DO run and
  typecheck the input: rendering input and output side by side is how it proves a conversion
  changed nothing. That is the one exception, and it installs nothing.
- Mapping-table first: each source's `mapping.ts` is its single source of truth. Every
  export of that library must have an entry (`mapped`/`partial`/`todo`) — the
  `mapping-coverage` test walks the vendored `RBC_EXPORTS` / `RBX_EXPORTS` / `BLOOMER_EXPORTS` list against it,
  in both directions, so the table cannot grow an entry for something the library never
  exported either. New coverage is a table edit (plus a `special` handler in `specials.ts`
  when structure changes). `bulma-classes`' table is `class-map.ts`, and its export list is
  Bulma's own stylesheet: `e2e/bulma-classes-vocabulary.test.ts` classifies every class in it.
- **A new source is a directory plus one registry line.** `src/sources/_shared/` holds
  everything library-agnostic: `jsx-utils.ts` (AST helpers + `TransformContext`),
  `props.ts` (the `PropAction` interpreter — its universal table is a parameter, not an
  import), `imports.ts` (binding collection and import aliasing), `specials-utils.ts`
  (`alignTarget`, `mergeClassName`, `parseIconClasses`, `modifierClass`, `restrictAsToTargets`,
  the `stripModifierProps` factory and the `makeStructuralHelpers` factory behind
  `replaceWithPlain`/`collapseOntoChild`), `viewports.ts` (Bulma's viewports → bestax's
  prop suffixes), `css-imports.ts` (the stylesheet-import pass), `bestax-import.ts` (seeding,
  building and merging the bestax-bulma import), `deps-common.ts` (the manifest steps after
  the source package is handled), and `make-styles-transform.ts` (the whole Bulma 0.9→v1
  stylesheet transform, parameterised by the source package's own specifiers). What stays
  per-source is the data — `mapping.ts`, `specials.ts`, `responsive.ts`, `deps.ts` — plus a
  `transform.ts` that orchestrates them. bloomer's is the plain one: its exports are flat, so it has no
  destructuring pass, no alias registry and no wrapping pass; the rbx and RBC copies still
  carry all of them.
- `'<source>'` must be added to `MIGRATE_SOURCE_VALUES` in `telemetry-worker/src/schema.ts`
  or its events are dropped at ingest; `check:conformance --only=telemetry-allowlists`
  fails until it is. That worker deploys to production on merge, so the two land together.
  The same check regex-scrapes each source's `index.ts` for
  `: MigrationSource = { … name: '…' }` — a declaration that doesn't match that shape is a
  hard violation, not a skip.
- Anything unsafe gets a `// TODO(bestax-migrate): <hint>` comment on the enclosing
  statement + a report entry — never a silent skip, never a best-guess rewrite of dynamic
  values.
- **A TODO message is a claim about bestax; read the component before writing it.** Four
  shipped on #613 were false (Modal "always closes on Escape"; a Field TODO naming `isGrouped`).
  The rbx e2e fails on an undocumented rule (RBC's does not); nothing checks the guidance is true.
- **A defect in one source's `transform.ts` is almost certainly in the others.** The fixes were
  ported RBC↔rbx on #613 and review kept finding the unported half. Fix the siblings in the same
  commit, or move the logic into `_shared/` (the alias registry, the literal/object helpers
  and the structural-handler helpers went there on #410 for exactly this reason).
- **Resolve references by binding, not by identifier text.** Every serious bug on #613 came
  from a name-keyed map (`function F(Header)` became `F(Card.Header)`). Use
  `makeAliasRegistry` from `_shared/imports.ts`: ancestors decide ownership, `scope.lookup`
  vetoes a nearer binding.
- **The report may only describe what actually happened.** The manifest headline said "bumped
  bulma" in most manifest shapes where nothing was bumped. Track each mutation; phrase from it.
- **The stylesheet and manifest passes must agree on what is removable.** Both report rather
  than remove `bulma-*` extensions, since markup outside the source may still use their classes.
  rbx enumerates them in `deps.ts` and `transform.ts`; the shared Sass pass flags any `bulma-*`.
- **Never change `bulma-ui` to make a migration cleaner.** Map onto the library as it is and
  emit a TODO otherwise. A gap earns a `bulma-ui` issue only if it is a bestax defect or the
  source is genuinely better, not merely different (#616 to #622 are the worked example).

## Architecture

- `src/cli.ts` — file walk + per-type routing (js-ish → jscodeshift transform,
  .scss/.sass → `transformStyles`, nearest package.json → `updateDependencies`) with an
  in-process runner (NOT jscodeshift's worker Runner: fragile from ESM, hides per-file
  stats). `src/runner.ts` is shared by CLI and tests. `--css bestax|bulma|keep` picks the
  stylesheet target, `bestax` unless the source sets its own `defaultCssMode` (bulma-classes
  keeps the app's stylesheet, which already styles every class it converts); `--no-deps`
  skips the manifest step. The CLI reads a source's
  `packageName` (defaulting to its `name`) as the import to look for; `null` means the source
  has no package, so no file is ever "still importing" it.
  A source with `htmlExtensions` (bulma-classes) also reads HTML pages: a page named on the
  command line always, a folder walk's only when `-e` lists its extension, exactly as for every
  other file (`html`, `htm`; React repos are full of `.html`
  nobody wants converted), and a walk without it hands each page it skipped to the source's
  `excludedHtml`, which reports the Bulma ones. `transformHtml` returns the module and where
  it goes; the CLI writes it there and never touches the page. A source without HTML support
  refuses a named page or an `-e` that lists `html` or `htm` rather than parsing HTML as
  JavaScript.
- `src/sources/react-bulma-components/` (and `rbx/`, same shape): `transform.ts`
  (orchestration: imports + css → per-element special/rename/responsive/props → import
  rewrite), `mapping.ts` (data), `specials.ts` (structural handlers), `responsive.ts`
  (breakpoint-object flattening), `styles.ts` (a thin call into `_shared/make-styles-transform.ts`,
  line-based because Sass has no jscodeshift parser), `deps.ts` (package.json updater; never
  runs an install). The PropAction interpreter and AST helpers live in `_shared/`.
- Components with no bestax equivalent (Element, Tile) keep a trimmed, TODO-annotated RBC
  import so the code still runs during gradual migration.
- `src/sources/bulma-classes/` is shaped differently, because it reads markup rather than a
  library's API: `class-map.ts` (data only, and imported directly by
  `scripts/gen-mcp-index.mjs` for the MCP server's `lookup_bulma_classes`, so no enums or
  runtime imports; a change to it wants `pnpm gen:mcp` in the same PR, and bestax-mcp's
  tests run this planner beside that tool), `plan.ts` (pure: one element's tag,
  classes, attributes, what its children became and the bestax components already around and
  inside it in, a conversion, a fold into its only child (a `fold` wrapper such as
  `.table-container`) or a TODO out; an entry that `absorbs` its only child (`.select`'s
  `<select>`) converts together with it, written in the child's place, and one that
  `countsChildren` (`.skeleton-lines`) writes their count and drops them, one that `readsGlyph`
  (`.icon`) takes the bare glyph `<i>` it holds as props (`glyph.ts`) and drops it, one that
  `buildsIcons` (`.icon-text`) takes its `.icon`s and the texts beside them as props, one that
  `buildsFile` (`.file`) takes its whole tree as props, inside a `Field` only, and one with a
  `parent` (`.pagination-link`) takes the place of the bare `<li>` its target renders too; an entry
  with `wrapsChildren` converts only beside one of its parts, so `transform.ts` plans children
  before their parents; and an element with no class to go by, a menu list's `<li>`, is found
  by the list around it and planned as the `PLACED` entry for that list), `rules.ts` (rule ids from a closed
  vocabulary, since the input is the app's own class strings and telemetry must never carry
  them), `project.ts` (what one file cannot show: which packages are Next.js App Router
  projects, which render JSX through a runtime other than React, and which are TypeScript, for
  the extension of the module an HTML page becomes, and which depend on Next.js, where that
  module imports no stylesheet, read once per run from every package.json the run touches) and `transform.ts` (finds the elements, reads a
  `className` a `clsx` or `classnames` call builds when it can read every argument, applies the
  plans, writes the import after the file's last one). The fixture render test runs the real
  `clsx` and `classnames`, which is why they are dev dependencies. An element converts only when the component renders the same markup; the render
  tests in `e2e/bulma-classes-*.test.ts` hold every entry to that, and
  `e2e/bulma-classes-props.test.ts` to the component's props type, which can be narrower than
  what it renders. One exception is deliberate (#1148): Font Awesome's own
  `<i aria-hidden="true">`, alone in a `.icon` that carries nothing assistive tech reads,
  converts to `Icon`, which writes that attribute on the `.icon` instead, because the glyph is
  hidden either way. The `.icon` may carry only what is on a whitelist (`hiddenMovesBeside`
  in its `readsGlyph` entry, which the MCP index carries too), checked by name whatever the
  value: a `title` or description would be hidden with
  it, and beside a `role` or `tabIndex` `Icon` writes no `aria-hidden` at all, so an expression
  there must refuse too. The planner marks it `movesHidden`, and the render tests compare it
  through `e2e/support/hidden-glyph.ts`, which moves the attribute up in that one shape, behind
  the same whitelist, and leaves every other difference to fail.
  Widen it only with the same care: a new exception is a claim that the page is unchanged for
  everyone who reads it, which identical markup proves and a normalization only asserts.
- The bulma-classes HTML input is `bulma-classes/html.ts`: parse the page, drop its scripts
  (each a report), emit its body as JSX with `pageDisplayOf` (an element's inline display, else
  the displays `display.ts` says Bulma's classes give it across viewports, else its tag's; a
  box `display.ts`'s `OUT_OF_FLOW` lists counts as no box, and where the cases differ the one
  kept is the one that keeps every run any of them would), wrap it in a module, run
  `transform.ts` on that module **unchanged**, at the path the module goes to so the rsc and
  jsx-runtime gates apply, then `finishModule` (numbers on plain elements, the import's blank
  line). What a component can't carry becomes a TODO line plus a report entry under the
  `html-*` rules in `html-rules.ts`, kept apart from `rules.ts` because none of it is about a
  Bulma class; an id carries only a fixed name, never the page's text, and falls back to the
  bare kind. Report entries name the page and its line, the planner's mapped back through the
  emitter's line map. `html-assets.ts` says what each stylesheet and script the page loads is,
  from its URL, and each extension's message names the bestax component only where reading it
  showed one does the extension's job. Under `--css bestax` or `--css bulma` the page's Bulma
  `<link>` becomes `import 'bulma/css/bulma.min.css'` in the module before the transform runs,
  so the shared stylesheet pass retargets it exactly as in a JSX file, and the CLI reads the
  module for `bulmaReferenced` as it reads a JSX file's output, so the manifest step agrees.
  Not in a Next.js app (its Pages Router takes a global stylesheet only in `pages/_app`), and
  not for a `<link media>`, which an import can't stand for. A local file is read only inside
  the page's folder, and a `<script src>` only when its type is JavaScript.
  `display.ts` is held to `bulma.css` by `e2e/bulma-classes-display.test.ts`, both tables and
  the out-of-flow list.
- `src/sources/_shared/html/` turns HTML into JSX source, library-agnostic: `parse.ts` (parse5,
  as a document or a fragment), `attributes.ts` (attribute to prop by `property-information`,
  plus what React needs written by hand; numbers stay strings for the planner, and
  `numberLiteral` writes them as numbers afterwards), `style.ts`, `text.ts` (escaping and
  comments), `whitespace.ts` (which whitespace renders, laid out the CSS way, each element's
  display coming from a `displayOf` hook that defaults to the HTML UA stylesheet) and `emit.ts`
  (the JSX on the source's own lines, a map from its lines back to the HTML's, and a report of
  what it could not write as it was). It never decides what a report becomes: the caller turns
  each into a TODO or a rule id. `e2e/html-converter-round-trip.test.ts` renders what it writes
  and compares the DOM with the page's, and `e2e/html-attributes-react.test.ts` holds
  `REACT_UNKNOWN` to React.
- `src/telemetry-core.ts` is a byte-for-byte copy of
  `create-bestax/src/telemetry-core.ts` — never edit it here; edit the
  create-bestax original and copy it over (`check:conformance
--only=telemetry-core` enforces identity). This package's payload builder and
  caps live in `src/telemetry.ts`; the caps are pinned to the ingest worker's
  bounds by `scripts/telemetry-contract.test.mjs`.

## Testing

- Fixture pairs: `__testfixtures__/<case>.input.tsx` → `.output.tsx`, exact-match. To
  update outputs after changing the transform, regenerate them by running the built
  transform over the inputs and reviewing the diff — do not hand-edit drift in.
  The HTML input's pairs are `bulma-classes/__testfixtures__/html/<case>.input.html` →
  `<case>.output.tsx`, regenerated the same way from the built `convertPage`
  (`dist/sources/bulma-classes/html.js`), each converted as `<case>.html` so its component is
  named from the case.
- The HTML input has e2es of its own. `e2e/bulma-classes-html-render.test.ts` wants one DOM
  from the page's body as parse5 builds it, the module before the planner rendered, and the
  module after it rendered, so the page matching the first module proves the converter and the
  modules matching each other prove the planner. It normalizes with `e2e/support/dom-canonical.ts`, the round-trip test's normalizer,
  given the converter's own display hook. `e2e/bulma-classes-html-kitchen-sink.test.ts` converts
  `fixtures/bulma-classes-html/` under `--css keep` and `--css bestax`, asserts each page's rules
  exactly, typechecks the output (`pages/leftovers.html` holds every pattern a component can't
  carry, its own script beside it in `pages/js/`, and its module stays out of the typecheck),
  and wants `html-file:exists` alone on a second run. Both kitchen sinks hold every rule they
  emit to the skill's references through `e2e/support/skill-rules.ts`.
- Kitchen sink e2e (`e2e/kitchen-sink.test.ts`): copies `fixtures/kitchen-sink` to a
  `mkdtemp`'d dir under `.e2e-tmp/`, migrates it, and runs `tsc --noEmit` on the output
  against the built bulma-ui (`turbo` orders the build; see `bestax-migrate#test` in root
  turbo.json). The scratch dir is **per process, never a fixed path**: `pnpm all` runs
  `test` and `test:coverage` in one turbo invocation, so two jest processes run this file
  concurrently and a shared path lets them clobber each other's rmSync/cpSync — the suite
  then fails as though the codemod had not run. `BESTAX_E2E_KEEP=1` leaves the dir behind.
  `src/leftovers.tsx` holds every intentionally-unsupported pattern — excluded from the
  typecheck, asserted via TODO rules instead. New unsupported patterns go there; new
  supported ones go in the other fixture files, which must stay TODO-free.
- `e2e/rendered-output.test.ts` renders the migrated kitchen sinks to HTML, and
  `e2e/support/render-module.ts` is the harness for any test that does. It loads React,
  `react-dom/server` and bestax-bulma from **bulma-ui's** directory, never this package's: the
  workspace can hold two React copies, and a component rendered by the other one fails on its
  first hook.
- Fixtures and `.e2e-tmp` are excluded from tsc/eslint/prettier at both root and package
  level (root `eslint.config.js` + both `.prettierignore`s) — keep them that way.
- Real-world corpus scripts. The ones that fetch read a pinned SHA as text only into
  `.e2e-tmp/`; all of them fail on any crash, and the library sources' on an
  unknown-component TODO too. Deliberately NOT in CI (no third-party fetches in the
  pipeline); run them before releases and after mapping changes, and eyeball the
  before/after diffs they write:
  - `validate:corpus` — react-bulma-components' own MIT Storybook stories →
    `.e2e-tmp/corpus-out/`.
  - `validate:corpus:rbx` — rbx's own MIT docs. rbx used **docz**, not Storybook, so the
    script extracts the `<Playground>` blocks out of its `*.docs.mdx` pages
    and rebuilds each page as one synthetic module written the way a consumer writes rbx.
    Output lands in `.e2e-tmp/corpus-out-rbx/`.
  - `validate:corpus:bloomer` — bloomer's own MIT docs: 39 React "Scene" `.tsx` files that
    render the library through a relative `src` import, migrated as written after that
    specifier becomes `'bloomer'`. Output lands in `.e2e-tmp/corpus-out-bloomer/`.
  - `validate:corpus:bulma-classes` — this repo's own stories, which mix components with raw
    Bulma markup; `--dir <path>` points it at any app's source instead. Nothing is fetched.
    Output lands in `.e2e-tmp/corpus-out-bulma-classes/`.
  - `validate:corpus:templates` — the HTML input over the awesome-bulma-templates repos, each
    fetched at the commit pinned in `scripts/lib/corpus-templates.mjs` (the one home of those
    pins, checked for shape only, so a moved pin is compared with its port issue by hand) and
    converted by the CLI with `--css bestax` in a scratch package where every page becomes
    `.tsx`. It needs the network, which is why it stays out of CI with the others, and it
    refuses to start until bestax-bulma is built, since tsc reads it for every module. It
    also fails on a module that no longer parses, on one tsc could not read, and on a rule the
    skill's references don't document (the kitchen sinks' check), and it lists the modules'
    `tsc` diagnostics without failing on them. `report.md` and `report.json` in
    `.e2e-tmp/corpus-templates/` are deterministic, so the reports from before and after a
    change to the HTML input or the class table diff line by line: that diff is how such a
    change is measured. A page's classes are counted from its DOM, so a refused page keeps
    its count. `e2e/corpus-templates.test.ts` tests the script's offline half.

  The corpus is the only check that sees breadth; the kitchen-sink e2e is the only one that
  sees bestax's _real_ prop names. Both are needed — the rbx e2e's typecheck caught
  mapping errors that 254 clean Playgrounds had not.

- After editing a test file, check the total test count did not drop: a range replacement
  between two non-adjacent `describe` blocks deleted 868 lines on #613 and the suite stayed green
  at 699 instead of 781. Compare the `Tests:` line from `pnpm test` (bare `jest` miscounts on ESM).

## Releases

Independent semantic-release, keyed off the `bestax-migrate` commit scope
(`release.config.js`, tag `bestax-migrate@x.y.z`).

**Publishing is shared, and this package is why it looks the way it does.**
Every package here publishes with `pnpm publish` rather than `npm publish`
(#436, then #532), through the plugin pair in `scripts/lib/pnpm-publish.mjs`.
The mechanism and the ways it fails quietly are documented once in
`VERSIONING.md` and in that helper. The `prepack` / `prepublishOnly` guard is
documented in `scripts/require-pnpm-publish.mjs`, and that header is worth
reading before touching it: why it keys on `npm_execpath` rather than the
inherited `npm_config_user_agent`, and why it refuses only packers it can
name instead of allow-listing pnpm. `pnpm check:conformance` reports a
violation if either hook is missing, so the exemption and the guard that
compensates for it cannot drift apart.

What is specific to bestax-migrate is the specifier that forced it.
`npm publish` does not resolve pnpm's `workspace:` protocol, so the
`workspace:^` below shipped verbatim in 1.0.0 and made the package
uninstallable (#412). That was first patched by a `prepack` hook
reimplementing pnpm's rewrite, and the reimplementation was wrong twice in one
review — which is the whole argument for handing the job to pnpm instead of
owning a subset of its logic. It was the only package carrying such a
specifier until #644 put one in every CLI, so all three tarballs are worth
inspecting after any change to how packing works: `pnpm -C bestax-migrate pack`,
and the same for `create-bestax` and `bestax-mcp`.

`@allxsmith/bestax-bulma` is a declared **dependency** (#644): the codemod is
built for the library and its manifest says so, the way bulma-ui declares
`bulma`. Nothing in `src/` imports it at runtime — it is the typecheck target
for the e2e and the source of the `valid*` constants the value-union tests diff
against — and consumers of the CLI now install it along with `bulma` and, under
npm's automatic peer install, `react`/`react-dom`. `publishable-manifests`
(#537) still forbids a workspace sibling in `dependencies`/`optionalDependencies`
**whatever the specifier says**; this one passes only because
`SIBLING_RUNTIME_DEPS` in `scripts/check-conformance.mjs` declares the exact
pair, and a test holds that declaration to the real manifest. One appearance
that is NOT this dependency: the e2e asserts the **migrated app's** manifest
depends on the library (`e2e/kitchen-sink.test.ts`) — that is the codemod's
output, which should depend on it, and no check reads it.

The skill lives at repo-root
`skills/bestax-migrate/`. It **is** bundled into create-bestax (settled in #385): the
original existing-sites-only policy lost to one uniform bundle, and the skill sits idle
in a fresh scaffold until legacy imports show up. The canonical roster of surfaces that
must agree on bundling lives in `create-bestax/CLAUDE.md`'s sync rules — don't restate
it here. When the mapping gains or loses coverage, update
`skills/bestax-migrate/references/` and the docs migration guide in the same PR.

### Codemod Registry

`codemod/` is this CLI's package in the Codemod Registry (`npx codemod bestax-migrate`).
The registry has no jscodeshift engine, so it is a wrapper: its workflows run
`npx --yes bestax-migrate@<version>` as a Codemod shell step. `.github/workflows/codemod-registry.yml`
publishes it by manual dispatch, and `scripts/codemod-registry.mjs` holds the checks.

- **The pins name one exact release and trail `package.json` between bumps.**
  codemod.yaml's `version`, each `bestax-migrate@` in `codemod/`, and `package.json` must
  agree when the workflow publishes, and the registry refuses a version it already has.
  semantic-release moves only `package.json`, so the pins stay put until
  `node scripts/codemod-registry.mjs bump` moves them in a `build(bestax-migrate)` change.
  The test sibling holds the committed pins to agreeing with each other and to not leading
  `package.json`. It never requires equality, because that would fail every open PR after
  each release.
- **`preview` exists because Codemod's `--dry-run` skips shell steps.** It is the same
  command with `--dry`. Keep the two workflows' params in step.
- **The `codemod/` workflows read params from `PARAM_*` environment variables**, which Codemod
  sets for shell steps. Never put `${{ params.* }}` in a `run:` line: Codemod substitutes it
  into the command before the shell parses it, so a value could run as code.
- **`codemod/` does not ship on npm.** `files` names only `dist`, and a test fails on an
  entry that could cover `codemod/`. Re-check with `pnpm -C bestax-migrate pack` after any
  change to `files`.
- **Nothing but the registry package goes in `codemod/`.** `codemod publish` uploads every
  file in it. That is why the `codemod` CLI's own package.json and lockfile live in
  `.github/codemod-cli/`, outside this package.
