Skip to content

ESM migration: convert lib batch 2 (ballot-interpreter group) - #9065

Draft
eventualbuddha wants to merge 10 commits into
mainfrom
brian/esm-lib-batch-2
Draft

ESM migration: convert lib batch 2 (ballot-interpreter group)#9065
eventualbuddha wants to merge 10 commits into
mainfrom
brian/esm-lib-batch-2

Conversation

@eventualbuddha

@eventualbuddha eventualbuddha commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Overview

Library batch 2: the ballot-interpreter group — the two native/Rust libraries plus the two fixture/rendering libraries they depend on. One commit per library, in top-down order so that every intermediate commit is itself valid (ballot-interpreter first, since it is the only remaining CommonJS importer of hmpb and bmd-ballot-fixtures), plus a fifth commit regenerating some design-backend snapshots (explained below).

  • ballot-interpreter (napi-rs native addon)
  • pdi-scanner (cargo binary)
  • hmpb
  • bmd-ballot-fixtures

Every other importer of these four was already converted in the app stack, so the batch is importer-closed: central-scan-backend, scan-backend, mark-scan-backend, mark-backend, print-backend, design-backend, design-frontend, dev-dock-backend, integration-test-utils, fixture-generators, test-decks and scan-integration-testing are all ESM already.

Reviewer's main focus

Three things here are more than codemod output.

1. The napi-rs native binding. build:rust-addon now passes --esm, so napi-rs generates the root index.js binding as ESM (it reaches the .node file through createRequire) instead of CommonJS. That matters because index.js sits at the package root, where a nested package.json can't scope it back to CommonJS — and under "type": "module" node would otherwise parse it as ESM and it would die on its own require calls. napi.ts now re-exports that binding instead of require()-ing it by path, which also puts the addon in the module graph rather than loading it out of band.

2. styled-components interop in hmpb. styled-components v5 ships CommonJS only, so under node's ESM interop import styled from 'styled-components' evaluates to module.exports rather than styledstyled.div is undefined, and each template literal throws at import time. Vitest and Vite hand back styled itself instead (vitest applies its own interopDefault; Vite resolves the package's ESM build), so a fix that only satisfies one of them breaks the other. libs/hmpb/src/styled.ts normalizes it once, and the six modules that used the default import take styled from there. Named imports (css, ServerStyleSheet) need nothing — node's CJS named-export detection handles those.

This one is worth knowing about beyond this PR: libs/ui has the same default import many times over, and it renders server-side inside backends, so it will need the same treatment when its batch comes up.

3. The bin/ dev CLIs. These are extensionless files with a node shebang, so node picks their module system from the package's package.json — the moment "type": "module" lands they are parsed as ESM. They used to install the esbuild-runner require hook and require TypeScript sources directly, which now fails twice over: the hook is CommonJS-only, and it resolves .js specifiers literally so it cannot find the .ts files those specifiers now point at. All five (ballot-interpreter's interpret, diagnostic, scoring-report; hmpb's generate-fixtures, generate-vxprint-test-print; and pdi-scanner's demo) now import the compiled output instead. hmpb's two package scripts run build:self first, so pnpm generate-fixtures still works as one command and can no longer run against a stale build.

4. A fifth commit updates 64 design-backend PDF snapshots. apps/design/backend/src/test_decks.test.ts mocks formatBallotHash to '0000000' so its snapshots are deterministic, but that mock only ever applied to design-backend's own code: hmpb was CommonJS, so its built modules reached @votingworks/types through require() in a dependency vitest treats as external, which bypasses the mock registry. The committed snapshots had a real hash (dc2aa66) baked into the ballot footer. Now that hmpb is ESM the mock reaches it too, and the footer renders what the test asked for.

Comparing every regenerated snapshot against its predecessor pixel by pixel, all differences fall in rows 2120-2142 of 2200 — the footer line, nothing else. The metadata QR code is byte-identical, which confirms metadata.ballotHash itself did not change (formatBallotHash is display formatting; the QR encodes the raw hash). This is a change in test-mock scope, not in behavior — vi.mock does not exist outside tests. Worth expecting more of this as libraries with heavily-mocked APIs (types, utils) convert.

Also worth a look: ballot-interpreter's cli.ts imported once from node:stream, which does not export it. Under CommonJS that quietly produced undefined and would only have thrown if a write buffer ever filled; ESM's static named-export check turns it into an immediate load error. It now comes from node:events, which is where it lives and matches the call.

Demo Video or Screenshot

N/A — module-system change, no user-facing behavior change.

Testing Plan

Per library, locally:

ballot-interpreter pdi-scanner hmpb bmd-ballot-fixtures
tsc --build clean clean clean clean
eslint (+ stylelint) clean clean clean clean
vitest run 155/155 27/27 144/144 7/7
built output imports under plain node yes, incl. native addon yes yes, incl. a styled-components template yes
CLIs all three print usage

The node import checks matter more than usual here: the unit tests run under vitest, which resolves modules the way Vite does, so they exercise the other side of every interop question in this PR. Importing the built output under plain node is what actually covers the way backends load these libraries — and it is what caught the styled-components breakage, which was invisible to both tsc (before the conversion) and vitest.

Repo-wide, on this branch: type-check passes for all 16 dependent packages (every app frontend and backend plus the dependent libs), and script/validate-monorepo is clean. CI covers the rest, including the integration tests that render ballots through hmpb in a real backend.

Checklist

  • I have prefixed my PR title with "VxDesign: ", "VxPollBook: ", or "HWTA: " if my change is specific to one of those products.
  • I have added logging where appropriate for any new user actions.
  • I have added the "user-facing-change" label to this PR, if relevant, to automate an announcement in #machine-product-updates.

🤖 Generated with Claude Code

eventualbuddha and others added 10 commits August 7, 2026 17:24
Mechanical changes produced by esm-codemod.cjs across the wave-1 leaf libraries
(custom-paper-handler, dev-dock frontend/backend, fixture-generators,
grout/test-utils, integration-test-utils, mark-flow-ui, networking, test-decks):
rewrite relative import specifiers with explicit .js extensions, replace
__dirname/__filename with import.meta equivalents, and flip package.json to
"type": "module" with an exports map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hand changes the codemod cannot make across the wave-1 leaf libraries:
- switch the bundled frontend libs (dev-dock-frontend, mark-flow-ui) to
  module: esnext / moduleResolution: bundler, matching the app frontends, so
  CJS default-import interop (@testing-library/user-event, styled-components)
  resolves;
- drop a now-unnecessary @ts-expect-error in mark-flow-ui's storybook config
  that bundler resolution makes valid;
- rename the CJS root config files (.lintstagedrc.js, mark-flow-ui's
  .stylelintrc.js) to .cjs so they keep loading under "type": "module".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#9079 landed the convention on main, along with a validate-monorepo check
that enforces it, so these nine packages need it too. Produced by re-running
the codemod, which now emits the subpath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
Codemod: `"type": "module"` + `exports`, explicit `.js` specifiers, `import.meta.dirname`. (The generated `exports` also fixes the stale `types` field, which pointed at `build/index.d.js`.)

Manual work, all of it about the napi-rs native addon and the dev CLIs:

- `build:rust-addon` now passes `napi build --esm`, so the generated root `index.js` binding is emitted as ESM (it loads the `.node` file through `createRequire`) instead of CommonJS. `index.js` is regenerated accordingly.
- `src/bubble-ballot-ts/napi.ts` re-exports that binding (`export * as napi`) instead of `require()`-ing it by path. The addon is now part of the module graph rather than loaded out of band, and the relative path still resolves from both `src/` and `build/`.
- `dts-header.d.ts` (the napi-rs header for the generated `index.d.ts`) gets an explicit `.js` specifier, so regenerating the type definitions keeps resolving under `node16`.
- The three `bin/` CLIs (`interpret`, `diagnostic`, `scoring-report`) import the compiled output instead of installing `esbuild-runner`. That hook is a CommonJS `require` hook: it cannot load ESM sources, and it resolves `.js` specifiers literally, so it cannot find the `.ts` files they point at either. They now need `pnpm build:self` first. Note these are extensionless files, so node picks their module system from this package.json — they were already ESM-parsed the moment `"type": "module"` landed, they just still had CommonJS bodies.
- `cli.ts` imported `once` from `node:stream`, which does not export it — under CommonJS that silently yielded `undefined` and would only have thrown if the write buffer ever filled; ESM's static named-export check turns it into a load error. Now imported from `node:events`, which is where it lives and matches the call.

Verified: `tsc --build` and `eslint` clean; `vitest run` 155/155; all three CLIs print usage; and `build/index.js` plus the native addon import successfully under plain `node`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
Codemod: `"type": "module"` + `exports`, explicit `.js` specifiers, `import.meta.dirname` (the two `__dirname` uses locate `target/release/pdictl` and the repo root, and resolve the same way from both `src/ts/` and `build/`).

Manual: `src/ts/demo` imports the compiled output instead of installing `esbuild-runner`. That hook is a CommonJS `require` hook, so it can neither load ESM sources nor resolve the `.js` specifiers they now use; the CLI needs `pnpm build:self` first. It is an extensionless file, so node picks its module system from this package.json.

The Rust side is unaffected: unlike ballot-interpreter there is no napi-rs JS binding here — `pdictl` is a plain cargo binary spawned as a child process.

Verified: `tsc --build` and `eslint` clean; `vitest run` 27/27; `build/index.js` imports under plain `node`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
Codemod: `"type": "module"` + `exports`, explicit `.js` specifiers (244 of them).

Manual work:

- **styled-components interop.** styled-components v5 ships CommonJS only, so under node's ESM interop `import styled from 'styled-components'` yields `module.exports` rather than `styled` — `styled.div` is `undefined` and every template literal throws at import time. tsc reports this too (`Property 'div' does not exist on type 'typeof import("@types/styled-components")'`), which is how it surfaced. Vitest and Vite hand us `styled` directly instead — vitest applies its own `interopDefault` and Vite resolves the package's ESM build — so the fix has to accept either shape. New `src/styled.ts` normalizes it once and the six modules that used the default import now take `styled` from there. Named imports (`css`, `ServerStyleSheet`) are unaffected: node's CJS named-export detection handles them.
- The two `bin/` CLIs import the compiled output instead of installing `esbuild-runner`, which is a CommonJS `require` hook and can neither load ESM sources nor resolve the `.js` specifiers they now use. Their package scripts (`generate-fixtures`, `generate-vxprint-test-print`) run `build:self` first, so the documented one-command workflow still works — and now can't silently run against a stale build. These are extensionless files, so node takes their module system from this package.json.
- `.lintstagedrc.js` renamed to `.cjs`.

`vite.config.ts` keeps `__dirname`: vite injects a shim for it when it bundles the config, which is how the already-converted frontends work.

Verified: `tsc --build`, `eslint` and `stylelint` clean; `vitest run` 144/144; `build/index.js`, `build/renderer.js` and a styled-components-using template all import successfully under plain `node`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
Codemod only: `"type": "module"` + `exports`, explicit `.js` specifiers. Plus renaming `.lintstagedrc.js` and `.stylelintrc.js` to `.cjs`, which node would otherwise read as ESM.

No interop work needed here. The two default imports of CommonJS dependencies (`react`, `tmp`) are safe: neither is transpiled output with an `__esModule` marker, so `module.exports` is the value the import expects — unlike styled-components in the hmpb commit.

Verified: `tsc --build`, `eslint`, `stylelint` clean; `vitest run` 7/7; `build/index.js` imports under plain `node`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
`bin/interpret`, `bin/diagnostic` and `bin/scoring-report` now run compiled output rather than transpiling `src` on the fly, so following the README's CLI section on a fresh checkout fails with `ERR_MODULE_NOT_FOUND` until the package is built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
Same as the previous batch: #9079 landed the convention and a
validate-monorepo check that enforces it. Produced by re-running the codemod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
Converting hmpb brings it into `vi.mock`'s reach, so the test's
`formatBallotHash` mock finally applies and the footer renders 0000000
instead of a real hash. That was already true in this batch; the snapshots
were lost rebasing onto main, which had regenerated all 64 of them for the
pdfjs-dist 5.4.296 upgrade in #9078, so both sides had touched every file
and neither side's version was correct on its own.

Regenerated rather than resolved by hand. Every one of the 64 differs from
main only within rows 2120-2142 of 2200 — the footer strip — so the ballot
content and QR codes are untouched. Only two exceeded
jest-image-snapshot's difference threshold and failed; the rest were stale
but under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPUVQen9XKKg9E4JC1yaU
@eventualbuddha
eventualbuddha force-pushed the brian/esm-lib-batch-2 branch from 68e5f18 to aeda367 Compare August 8, 2026 00:45
Base automatically changed from brian/esm-lib-batch-1 to main August 13, 2026 00:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant