ESM migration: convert lib batch 2 (ballot-interpreter group) - #9065
Draft
eventualbuddha wants to merge 10 commits into
Draft
ESM migration: convert lib batch 2 (ballot-interpreter group)#9065eventualbuddha wants to merge 10 commits into
eventualbuddha wants to merge 10 commits into
Conversation
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 5, 2026 21:36
a68ebac to
907b904
Compare
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 5, 2026 22:11
907b904 to
bb3d9f1
Compare
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 5, 2026 22:19
bb3d9f1 to
9fe4f01
Compare
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 5, 2026 22:33
eb4d516 to
8c52093
Compare
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 5, 2026 22:37
8c52093 to
51adad7
Compare
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 5, 2026 22:39
51adad7 to
dec2b1a
Compare
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 6, 2026 00:45
dec2b1a to
c413442
Compare
eventualbuddha
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 6, 2026 17:31
c413442 to
68e5f18
Compare
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
force-pushed
the
brian/esm-lib-batch-2
branch
from
August 8, 2026 00:45
68e5f18 to
aeda367
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-interpreterfirst, since it is the only remaining CommonJS importer ofhmpbandbmd-ballot-fixtures), plus a fifth commit regenerating some design-backend snapshots (explained below).ballot-interpreter(napi-rs native addon)pdi-scanner(cargo binary)hmpbbmd-ballot-fixturesEvery 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-decksandscan-integration-testingare all ESM already.Reviewer's main focus
Three things here are more than codemod output.
1. The napi-rs native binding.
build:rust-addonnow passes--esm, so napi-rs generates the rootindex.jsbinding as ESM (it reaches the.nodefile throughcreateRequire) instead of CommonJS. That matters becauseindex.jssits at the package root, where a nestedpackage.jsoncan't scope it back to CommonJS — and under"type": "module"node would otherwise parse it as ESM and it would die on its ownrequirecalls.napi.tsnow re-exports that binding instead ofrequire()-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 tomodule.exportsrather thanstyled—styled.divisundefined, and each template literal throws at import time. Vitest and Vite hand backstyleditself instead (vitest applies its owninteropDefault; Vite resolves the package's ESM build), so a fix that only satisfies one of them breaks the other.libs/hmpb/src/styled.tsnormalizes it once, and the six modules that used the default import takestyledfrom 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/uihas 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'spackage.json— the moment"type": "module"lands they are parsed as ESM. They used to install theesbuild-runnerrequire hook and require TypeScript sources directly, which now fails twice over: the hook is CommonJS-only, and it resolves.jsspecifiers literally so it cannot find the.tsfiles those specifiers now point at. All five (ballot-interpreter'sinterpret,diagnostic,scoring-report;hmpb'sgenerate-fixtures,generate-vxprint-test-print; andpdi-scanner'sdemo) now import the compiled output instead. hmpb's two package scripts runbuild:selffirst, sopnpm generate-fixturesstill 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.tsmocksformatBallotHashto'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/typesthroughrequire()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.ballotHashitself did not change (formatBallotHashis display formatting; the QR encodes the raw hash). This is a change in test-mock scope, not in behavior —vi.mockdoes not exist outside tests. Worth expecting more of this as libraries with heavily-mocked APIs (types,utils) convert.Also worth a look:
ballot-interpreter'scli.tsimportedoncefromnode:stream, which does not export it. Under CommonJS that quietly producedundefinedand 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 fromnode: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:
tsc --buildeslint(+stylelint)vitest runnodeThe
nodeimport 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 plainnodeis what actually covers the way backends load these libraries — and it is what caught the styled-components breakage, which was invisible to bothtsc(before the conversion) and vitest.Repo-wide, on this branch:
type-checkpasses for all 16 dependent packages (every app frontend and backend plus the dependent libs), andscript/validate-monorepois clean. CI covers the rest, including the integration tests that render ballots through hmpb in a real backend.Checklist
🤖 Generated with Claude Code