CLAUDE.md@bestax-migrate · diff
git:20261018.fe05614 to git:20261018.c2e22b4
3 added, 2 removed. Audit B to B.
# 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 (`movesBeside` in
- `plan.ts`), checked by name whatever the value: a `title` or description would be hidden with
+ 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.