ADR 0001: Rewrite runtime and toolchain, run .ts directly, defer the bundler
Status: Accepted (2026-07-11) · Phase 3 kickoff · build slice resolved 2026-07-19 (see addendum)
Context
STRATEGY.md decided the rewrite stack up front: strict TypeScript, ESM-only, Vitest, Biome, and a tsup/unbuild-class bundler emitting ESM plus .d.ts. Those were sound defaults written before the rewrite began. On starting Phase 3 two facts changed the cheapest correct path for the first modules:
- The dev environment runs Node 24, which executes
.tsfiles directly via type-stripping, with no transpile step and no loader, and an.mjscanimporta local.tsmodule. - The corpus is what pins the product's behavior and already runs via plain
node. The rewrite only needs to be reachable from a corpus adapter and type-checked; it does not need to be bundled to be proven correct.
Standing up Vitest plus Biome plus a bundler now is real dependency and config weight for no correctness gain on a single pure module, and it is the highest-drift work in the plan (PROGRESS.md guardrail). Deferring it keeps the first slices lean and dependency-clean (CLAUDE.md §2).
Decision
- Runtime/test path uses Node's native
.tsexecution, with no build step. Therewrite.mjscorpus adapter importssrc/**/*.tsdirectly; unit tests run under the built-innode --testrunner on.tsfiles. Local TS imports use explicit.tsextensions. tscis the type-safety gate, not a build tool.npm run typecheck(tsc --noEmit -p tsconfig.json) enforces the full strict flag set (strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride,verbatimModuleSyntax, …). TypeScript is pinned to 5.x.- The new tree is ESM, scoped by
src/package.json("type": "module"). The legacy CommonJS root (lib/,excel.js) is left byte-for-byte intact, with no root"type"flip that would reinterpret legacy.jsas ESM. - Vitest, Biome, and the ESM/
.d.tsbundler are deferred to a dedicated toolchain-standup slice, to run once enough ofsrc/exists to justify the packaging/lint config and after the legacy Grunt/Babel/Mocha rip-out is scheduled.
Consequences
- Positive: zero-build inner loop; no new runtime/test deps beyond
typescript; legacy tree untouched so the freeze guardrail holds; strict typing still enforced. - Negative / deferred: no published bundle or emitted
.d.tsyet, which is fine because nothing is published in Phase 3; two test runners transiently (node --testforsrc/, Mocha for legacyspec/) until the swap;node --testassertions are written in a Vitest-portable style (node:assert/strict) to make that migration mechanical. - Revisit when: a target runtime without
.tsexecution must run the code, a publishable artifact is needed (Phase 4), orsrc/is large enough that Biome's lint/format and Vitest's watch/coverage pay for their config.
Addendum (2026-07-19): the deferred bundler resolved to no bundler
Phase 4's publishable-build slice revisited the deferred "tsup/unbuild-class bundler" and rejected it. The emit requirement is narrow: rewrite the source's mandatory .ts import specifiers (the no-build dev/test runtime needs them) to .js, and emit .d.ts. TypeScript 5.7+ does exactly the first with rewriteRelativeImportExtensions, and tsc already does the second. So the build is pure tsc (tsconfig.build.json extends the strict gate config, flips noEmit off, adds declaration plus rewriteRelativeImportExtensions, emits to dist/). No bundler earns its place: no new dependency, no config to maintain, no tree-shake/minify step we don't need for a Node-targeted ESM library.
Amended 2026-08-08: still pure tsc, now two invocations of it. removeComments is whole-emit, so one pass cannot both strip the implementation prose from the JS and keep the JSDoc in the .d.ts. The passes therefore split by audience, with declaration moving to tsconfig.build.dts.json. This is the one thing a bundler would have been asked for and it did not take one.
Decisions that rode along:
exports/main/typespoint atdist/;filesshipsdistonly (no maps, nosrc) to keep the tarball lean (~237 KB packed). Maps are omitted deliberately, because maintainers debugsrc/directly, neverdist/.enginessplit: the compiled artifact is ES2022 ESM and supports Node>=18(declared inengines); the dev toolchain still needs Node 24 for.tsexecution and is pinned via.nvmrc.privatedropped; publish is guarded byprepublishOnly(build, full test,smoke:dist,size). The definitive package name remains the one human decision deferred to the rebrand slice.- Two new CI-enforced guards:
smoke:distloads the compiled artifact as a consumer would and asserts a write→read round-trip, catching emit-shaped breakage typecheck can't;sizefails if the emitted runtime JS crosses a 600 KB budget (currently ~489 KB). Both run in a dedicatedBuildworkflow.
Addendum (2026-07-20): full corpus runs against the emitted artifact too
The stripping-against-transpilation question was revisited. No .ts-execution-free runtime materialized, so type-stripping stays the dev/test inner loop, per §0's framing. But it surfaced the one real gap: test:src and the corpus's default adapter only ever exercise stripped src/, while consumers run the tsc-emitted dist/. smoke:dist guarded a single round-trip against that divergence; the full 671-behavior corpus did not.
Closed cheaply, with no new inner-loop cost: the rewrite corpus adapter now retargets its implementation imports via CORPUS_TARGET (default src/.ts; dist/.js mirrors the tree since rootDir=src). pnpm run corpus:dist runs the entire behavioral corpus against the emitted JS, wired into the Build workflow after smoke:dist so it reuses the single build. The zero-dep type-stripping loop is unchanged for day-to-day work; emit parity is now proven by the whole corpus, not one smoke case.
Vitest and Biome remain deferred to the toolchain-standup slice.
Addendum (2026-07-21): the deferred package name is resolved
The one open human decision from the addendum above, the package name, is settled: @shbernal/ts-xlsx is final. See ADR-0015, which also states the SemVer policy and confirms the first published version is 1.0.0.